Merge pull request #15208 from supabase/chore/shift-storage-buckets-to-react-query

Chore/shift storage buckets to react query
This commit is contained in:
Joshen Lim
2023-06-21 12:20:31 +08:00
committed by GitHub
19 changed files with 444 additions and 248 deletions

View File

@@ -1,29 +1,29 @@
import clsx from 'clsx'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import {
Alert,
Button,
Modal,
Input,
Toggle,
Form,
Collapsible,
Form,
IconChevronDown,
Input,
Listbox,
Modal,
Toggle,
} from 'ui'
import { BucketCreatePayload } from './Storage.types'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { useParams } from 'common'
import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants'
import {
convertToBytes,
convertFromBytes,
convertToBytes,
} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils'
import { useStore } from 'hooks'
import { useParams } from 'common'
import { IS_PLATFORM } from 'lib/constants'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useRouter } from 'next/router'
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
import { useStore } from 'hooks'
import { IS_PLATFORM } from 'lib/constants'
export interface CreateBucketModalProps {
visible: boolean
@@ -34,9 +34,8 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
const { ui } = useStore()
const { ref } = useParams()
const router = useRouter()
const storageExplorerStore = useStorageStore()
const { createBucket } = storageExplorerStore
const { mutateAsync: createBucket } = useBucketCreateMutation()
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
@@ -65,29 +64,36 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
}
const onSubmit = async (values: any, { setSubmitting }: any) => {
const payload: BucketCreatePayload = {
id: values.name,
public: 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())
: null,
}
if (!ref) return console.error('Project ref is required')
setSubmitting(true)
const res = await createBucket(payload)
if (res.error) {
setSubmitting(false)
} else {
try {
const res = await createBucket({
projectRef: ref,
id: values.name,
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())
: null,
})
ui.setNotification({
category: 'success',
message: `Successfully created bucket "${res.name}"`,
message: `Successfully created bucket ${res.name}`,
})
router.push(`/project/${ref}/storage/buckets/${res.name}`)
onClose()
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to create bucket: ${error.message}`,
})
setSubmitting(false)
}
}
@@ -104,7 +110,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
visible={visible}
size="medium"
header="Create storage bucket"
onCancel={onClose}
onCancel={() => onClose()}
>
<Form
validateOnBlur={false}
@@ -193,6 +199,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
</div>
<div className="col-span-4">
<Listbox
id="size_limit_units"
disabled={false}
value={selectedUnit}
onChange={setSelectedUnit}
@@ -234,7 +241,12 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
<div className="w-full border-t border-scale-500 !mt-0" />
<Modal.Content>
<div className="flex items-center space-x-2 justify-end">
<Button type="default" disabled={isSubmitting} onClick={() => onClose()}>
<Button
type="default"
htmlType="button"
disabled={isSubmitting}
onClick={() => onClose()}
>
Cancel
</Button>
<Button

View File

@@ -1,28 +1,28 @@
import clsx from 'clsx'
import { useParams } from 'common'
import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants'
import {
convertFromBytes,
convertToBytes,
} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation'
import { useStore } from 'hooks'
import { IS_PLATFORM } from 'lib/constants'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import {
Alert,
Button,
Modal,
Input,
Toggle,
Form,
Collapsible,
Form,
IconChevronDown,
Input,
Listbox,
Modal,
Toggle,
} from 'ui'
import { BucketUpdatePayload, StorageBucket } from './Storage.types'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants'
import {
convertToBytes,
convertFromBytes,
} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils'
import { useStore } from 'hooks'
import { useParams } from 'common'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { IS_PLATFORM } from 'lib/constants'
import { StorageBucket } from './Storage.types'
export interface EditBucketModalProps {
visible: boolean
@@ -33,9 +33,8 @@ export interface EditBucketModalProps {
const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {
const { ui } = useStore()
const { ref } = useParams()
const storageExplorerStore = useStorageStore()
const { editBucket } = storageExplorerStore
const { mutateAsync: updateBucket } = useBucketUpdateMutation()
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
@@ -52,31 +51,33 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) =>
}
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (bucket === undefined) {
return console.error('Bucket is required')
}
if (bucket === undefined) return console.error('Bucket is required')
if (ref === undefined) return console.error('Project ref is required')
const payload: BucketUpdatePayload = {
public: 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())
try {
await updateBucket({
projectRef: ref,
id: bucket.id,
isPublic: values.public,
file_size_limit: values.has_file_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit)
: null,
}
setSubmitting(true)
const res = await editBucket(bucket, payload)
if (res.error) {
setSubmitting(false)
} else {
allowed_mime_types:
values.allowed_mime_types.length > 0
? values.allowed_mime_types.split(',').map((x: string) => x.trim())
: null,
})
ui.setNotification({
category: 'success',
message: `Successfully updated bucket "${bucket.name}"`,
})
onClose()
} catch (error: any) {
setSubmitting(false)
ui.setNotification({
category: 'success',
message: `Failed to update bucket: ${error.message}`,
})
}
}
@@ -216,6 +217,7 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) =>
</div>
<div className="col-span-4">
<Listbox
id="size_limit_units"
disabled={false}
value={selectedUnit}
onChange={setSelectedUnit}

View File

@@ -14,16 +14,16 @@ import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
// is a unique project id/marker so we'll redirect the user to the
// highest common route with just projectRef in the router queries.
const sanitizeRoute = (route: string, routerQueries: ParsedUrlQuery) => {
export const sanitizeRoute = (route: string, routerQueries: ParsedUrlQuery) => {
const queryArray = Object.entries(routerQueries)
if (queryArray.length > 1) {
// account for query string, if exists (example: /logs/explorer?q=select...)
const hasQueryString = queryArray.some(([key]) => key === 'q')
// [Joshen] Ideally we shouldn't use hard coded numbers, but temp workaround
// for storage bucket route since its longer
const isStorageBucketRoute = 'bucketId' in routerQueries
return route
.split('/')
.slice(0, hasQueryString ? 5 : 4)
.slice(0, isStorageBucketRoute ? 5 : 4)
.join('/')
} else {
return route

View File

@@ -16,7 +16,11 @@ const NavigationIconButton: FC<Props> = ({ route, isActive = false }) => {
<Tooltip.Trigger>
<ConditionalWrap
condition={route.link !== undefined}
wrap={(children) => <Link href={route.link!}>{children}</Link>}
wrap={(children) => (
<Link href={route.link!}>
<a>{children}</a>
</Link>
)}
>
<span
className={[

View File

@@ -1,14 +1,14 @@
import clsx from 'clsx'
import Link from 'next/link'
import { noop } from 'lodash'
import { Badge, Button, Dropdown, IconChevronDown, IconEdit2, IconLoader, IconTrash } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import clsx from 'clsx'
import { noop } from 'lodash'
import Link from 'next/link'
import { Badge, Button, Dropdown, IconChevronDown, IconEdit2, IconLoader, IconTrash } from 'ui'
import { Bucket } from 'data/storage/buckets-query'
import { checkPermissions } from 'hooks'
import { STORAGE_ROW_STATUS } from 'components/to-be-cleaned/Storage/Storage.constants'
interface BucketRowProps {
bucket: any
export interface BucketRowProps {
bucket: Bucket
projectRef?: string
isSelected: boolean
onSelectDeleteBucket: (bucket: any) => void
@@ -16,7 +16,7 @@ interface BucketRowProps {
}
const BucketRow = ({
bucket = {},
bucket,
projectRef = '',
isSelected = false,
onSelectDeleteBucket = noop,
@@ -46,9 +46,10 @@ const BucketRow = ({
</a>
</Link>
<div className="pr-3">
{bucket.status === STORAGE_ROW_STATUS.LOADING ? (
{/* [JOSHEN TODO] need to change this */}
{false ? (
<IconLoader className="animate-spin" size={16} strokeWidth={2} />
) : canUpdateBuckets && bucket.status === STORAGE_ROW_STATUS.READY && isSelected ? (
) : canUpdateBuckets && isSelected ? (
<Dropdown
side="bottom"
align="start"

View File

@@ -1,33 +1,27 @@
import { FC, ReactNode, useEffect } from 'react'
import { find, filter, get as _get } from 'lodash'
import { observer } from 'mobx-react-lite'
import { ReactNode, useEffect } from 'react'
import { useStore, withAuth } from 'hooks'
import { useParams } from 'common/hooks'
import DeleteBucketModal from 'components/to-be-cleaned/Storage/DeleteBucketModal'
import { AutoApiService, useProjectApiQuery } from 'data/config/project-api-query'
import { useStore, withAuth } from 'hooks'
import { PROJECT_STATUS } from 'lib/constants'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import ProjectLayout from '../'
import StorageMenu from './StorageMenu'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { formatPoliciesForStorage } from 'components/to-be-cleaned/Storage/Storage.utils'
import DeleteBucketModal from 'components/to-be-cleaned/Storage/DeleteBucketModal'
import { PROJECT_STATUS } from 'lib/constants'
interface Props {
export interface StorageLayoutProps {
title: string
children: ReactNode
}
const StorageLayout: FC<Props> = ({ title, children }) => {
const { ui, meta } = useStore()
const StorageLayout = ({ title, children }: StorageLayoutProps) => {
const { ui } = useStore()
const { ref: projectRef } = useParams()
const storageExplorerStore = useStorageStore()
const {
selectedBucketToEdit,
closeDeleteBucketModal,
showDeleteBucketModal,
deleteBucket,
buckets,
} = storageExplorerStore || {}
const { selectedBucketToEdit, closeDeleteBucketModal, showDeleteBucketModal } =
storageExplorerStore || {}
const { data: settings, isLoading } = useProjectApiQuery({ projectRef })
const apiService = settings?.autoApiService
@@ -48,7 +42,6 @@ const StorageLayout: FC<Props> = ({ title, children }) => {
apiService.serviceApiKey,
apiService.protocol
)
await storageExplorerStore.fetchBuckets()
} else {
ui.setNotification({
category: 'error',
@@ -59,38 +52,13 @@ const StorageLayout: FC<Props> = ({ title, children }) => {
storageExplorerStore.setLoaded(true)
}
const onSelectDeleteBucket = async (bucket: any) => {
const res = await deleteBucket(bucket)
// Ideally this should be within deleteBucket as its a necessary side effect
// but we'll do so once we refactor to remove the StorageExplorerStore
if (res) {
const policies = meta.policies.list()
const storageObjectsPolicies = filter(policies, { table: 'objects' })
const formattedStorageObjectPolicies = formatPoliciesForStorage(
buckets,
storageObjectsPolicies
)
const bucketPolicies = _get(
find(formattedStorageObjectPolicies, { name: bucket.name }),
['policies'],
[]
)
await Promise.all(
bucketPolicies.map((policy: any) => {
meta.policies.del(policy.id)
})
)
}
}
return (
<ProjectLayout title={title || 'Storage'} product="Storage" productMenu={<StorageMenu />}>
{children}
<DeleteBucketModal
visible={showDeleteBucketModal}
bucket={selectedBucketToEdit}
onSelectCancel={closeDeleteBucketModal}
onSelectDelete={onSelectDeleteBucket}
onClose={closeDeleteBucketModal}
/>
</ProjectLayout>
)

View File

@@ -1,4 +1,4 @@
import { FC, useState } from 'react'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
@@ -14,10 +14,9 @@ import { StorageBucket } from 'components/interfaces/Storage/Storage.types'
import EditBucketModal from 'components/interfaces/Storage/EditBucketModal'
import CreateBucketModal from 'components/interfaces/Storage/CreateBucketModal'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useBucketsQuery } from 'data/storage/buckets-query'
interface Props {}
const StorageMenu: FC<Props> = () => {
const StorageMenu = () => {
const router = useRouter()
const { ref, bucketId } = useParams()
const [showCreateBucketModal, setShowCreateBucketModal] = useState(false)
@@ -32,7 +31,10 @@ const StorageMenu: FC<Props> = () => {
| 'logs'
const storageExplorerStore = useStorageStore()
const { loaded, buckets, openDeleteBucketModal } = storageExplorerStore || {}
const { data, isLoading } = useBucketsQuery({ projectRef: ref })
const { openDeleteBucketModal } = storageExplorerStore || {}
const buckets = data ?? []
return (
<>
@@ -78,11 +80,11 @@ const StorageMenu: FC<Props> = () => {
<div className="">
<div>
<Menu.Group title="All buckets" />
{!loaded ? (
<div className="space-y-2">
{isLoading ? (
<div className="space-y-2 mx-2">
<ShimmeringLoader className="!py-2.5" />
<ShimmeringLoader className="!py-2.5" />
<ShimmeringLoader className="!py-2.5" />
<ShimmeringLoader className="!py-2.5 w-3/4" />
<ShimmeringLoader className="!py-2.5 w-1/2" />
</div>
) : (
<>

View File

@@ -1,30 +1,74 @@
import { FC, useEffect, useState } from 'react'
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
import { get as _get, find } from 'lodash'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
interface Props {
import { useParams } from 'common'
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
import { useBucketDeleteMutation } from 'data/storage/bucket-delete-mutation'
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
import { useStore } from 'hooks'
import { formatPoliciesForStorage } from './Storage.utils'
export interface DeleteBucketModalProps {
visible: boolean
bucket: any
onSelectCancel: () => void
onSelectDelete: (bucket: any) => void
bucket: Bucket
onClose: () => void
}
const DeleteBucketModal: FC<Props> = ({
visible = false,
bucket = {},
onSelectCancel,
onSelectDelete,
}) => {
const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketModalProps) => {
const router = useRouter()
const { ui, meta } = useStore()
const { ref: projectRef } = useParams()
const [deleting, setDeleting] = useState(false)
const [validationInput, setValidationInput] = useState('')
const { data } = useBucketsQuery({ projectRef })
const { mutateAsync: deleteBucket } = useBucketDeleteMutation()
const buckets = data ?? []
useEffect(() => {
setValidationInput('')
setDeleting(false)
}, [visible])
const onConfirmDelete = () => {
const onDeleteBucket = async () => {
if (!projectRef) return console.error('Project ref is required')
setDeleting(true)
onSelectDelete(bucket)
try {
await deleteBucket({ projectRef, id: bucket.id })
// Clean up policies from the corresponding bucket that was deleted
await meta.policies.loadBySchema('storage')
const policies = meta.policies.list()
const storageObjectsPolicies = policies.filter((policy) => policy.table === 'objects')
const formattedStorageObjectPolicies = formatPoliciesForStorage(
buckets,
storageObjectsPolicies
)
const bucketPolicies = _get(
find(formattedStorageObjectPolicies, { name: bucket.name }),
['policies'],
[]
)
await Promise.all(
bucketPolicies.map((policy: any) => {
meta.policies.del(policy.id)
})
)
ui.setNotification({
category: 'success',
message: `Successfully deleted bucket ${bucket.name}`,
})
router.push(`/project/${projectRef}/storage/buckets`)
onClose()
} catch (error: any) {
setDeleting(false)
ui.setNotification({
category: 'error',
message: `Failed to delete bucket: ${error.message}`,
})
}
}
return (
@@ -32,8 +76,8 @@ const DeleteBucketModal: FC<Props> = ({
visible={visible}
title={`Confirm deletion of ${bucket.name}`}
confirmPlaceholder="Type in name of bucket"
onConfirm={onConfirmDelete}
onCancel={onSelectCancel}
onConfirm={onDeleteBucket}
onCancel={onClose}
confirmString={bucket.name}
loading={deleting}
text={

View File

@@ -184,7 +184,7 @@ const FileExplorerColumn = ({
<div
className={`
${fullWidth ? 'w-full' : 'w-64 border-r border-gray-500'}
my-1 flex flex-shrink-0 flex-col space-y-1 overflow-auto
px-2 py-1 my-1 flex flex-shrink-0 flex-col space-y-2 overflow-auto
`}
>
<ShimmeringLoader />

View File

@@ -12,11 +12,18 @@ import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { PolicyEditorModal } from 'components/interfaces/Auth/Policies'
import { useBucketsQuery } from 'data/storage/buckets-query'
import { useParams } from 'common'
const StoragePolicies = () => {
const { ui, meta } = useStore()
const { ref: projectRef } = useParams()
const storageStore = useStorageStore()
const { loaded, buckets } = storageStore
const { loaded } = storageStore
const { data } = useBucketsQuery({ projectRef })
const buckets = data ?? []
const roles = meta.roles.list((role) => !meta.roles.systemRoles.includes(role.name))

View File

@@ -0,0 +1,56 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { post } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { storageKeys } from './keys'
export type BucketCreateVariables = {
projectRef: string
id: string
isPublic: boolean
file_size_limit: number | null
allowed_mime_types: string[] | null
}
export async function createBucket({
projectRef,
id,
isPublic,
file_size_limit,
allowed_mime_types,
}: BucketCreateVariables) {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is requried')
const response = await post(`${API_URL}/storage/${projectRef}/buckets`, {
id,
public: isPublic,
file_size_limit,
allowed_mime_types,
})
if (response.error) throw response.error
return response
}
type BucketCreateData = Awaited<ReturnType<typeof createBucket>>
export const useBucketCreateMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<BucketCreateData, unknown, BucketCreateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BucketCreateData, unknown, BucketCreateVariables>(
(vars) => createBucket(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(storageKeys.buckets(projectRef))
await onSuccess?.(data, variables, context)
},
...options,
}
)
}

View File

@@ -0,0 +1,45 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { delete_, post } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { storageKeys } from './keys'
export type BucketDeleteVariables = {
projectRef: string
id: string
}
export async function deleteBucket({ projectRef, id }: BucketDeleteVariables) {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is requried')
const emptyBucketRes = await post(`${API_URL}/storage/${projectRef}/buckets/${id}/empty`, {})
if (emptyBucketRes.error) throw emptyBucketRes.error
const response = await delete_(`${API_URL}/storage/${projectRef}/buckets/${id}`)
if (response.error) throw response.error
return response
}
type BucketDeleteData = Awaited<ReturnType<typeof deleteBucket>>
export const useBucketDeleteMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<BucketDeleteData, unknown, BucketDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BucketDeleteData, unknown, BucketDeleteVariables>(
(vars) => deleteBucket(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(storageKeys.buckets(projectRef))
await onSuccess?.(data, variables, context)
},
...options,
}
)
}

View File

@@ -0,0 +1,55 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { patch } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { storageKeys } from './keys'
export type BucketUpdateVariables = {
projectRef: string
id: string
isPublic: boolean
file_size_limit: number | null
allowed_mime_types: string[] | null
}
export async function updateBucket({
projectRef,
id,
isPublic,
file_size_limit,
allowed_mime_types,
}: BucketUpdateVariables) {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is requried')
const response = await patch(`${API_URL}/storage/${projectRef}/buckets/${id}`, {
public: isPublic,
file_size_limit,
allowed_mime_types,
})
if (response.error) throw response.error
return response
}
type BucketUpdateData = Awaited<ReturnType<typeof updateBucket>>
export const useBucketUpdateMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<BucketUpdateData, unknown, BucketUpdateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BucketUpdateData, unknown, BucketUpdateVariables>(
(vars) => updateBucket(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(storageKeys.buckets(projectRef))
await onSuccess?.(data, variables, context)
},
...options,
}
)
}

View File

@@ -0,0 +1,51 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_URL, IS_PLATFORM } from 'lib/constants'
import { useCallback } from 'react'
import { storageKeys } from './keys'
export type BucketsVariables = { projectRef?: string }
export type Bucket = {
id: string
name: string
owner: string
public: boolean
created_at: string
updated_at: string
file_size_limit: null | number
allowed_mime_types: null | string[]
}
export async function getBuckets({ projectRef }: BucketsVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const response = await get(`${API_URL}/storage/${projectRef}/buckets`, { signal })
if (response.error) throw response.error
return response as Bucket[]
}
export type BucketsData = Awaited<ReturnType<typeof getBuckets>>
export type BucketsError = unknown
export const useBucketsQuery = <TData = BucketsData>(
{ projectRef }: BucketsVariables,
{ enabled = true, ...options }: UseQueryOptions<BucketsData, BucketsError, TData> = {}
) =>
useQuery<BucketsData, BucketsError, TData>(
storageKeys.buckets(projectRef),
({ signal }) => getBuckets({ projectRef }, signal),
{ enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined', ...options }
)
export const useBucketsPrefetch = ({ projectRef }: BucketsVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef) {
client.prefetchQuery(storageKeys.buckets(projectRef), ({ signal }) =>
getBuckets({ projectRef }, signal)
)
}
}, [projectRef])
}

View File

@@ -0,0 +1,3 @@
export const storageKeys = {
buckets: (projectRef: string | undefined) => ['projects', projectRef, 'buckets'] as const,
}

View File

@@ -440,18 +440,6 @@ class StorageExplorerStore {
/* Methods that involve the storage client library */
/* Bucket CRUD */
createBucket = async (payload) => {
const res = await post(`${this.endpoint}/buckets`, payload)
if (res.error) {
this.ui.setNotification({ category: 'error', message: res.error.message })
return res
} else {
await this.fetchBuckets()
return res
}
}
openBucket = async (bucket) => {
const { id, name } = bucket
const columnIndex = -1
@@ -461,59 +449,6 @@ class StorageExplorerStore {
}
}
fetchBuckets = async () => {
const res = await get(`${this.endpoint}/buckets`)
if (res.error) return this.ui.setNotification({ category: 'error', message: res.error.message })
const formattedBuckets = res.map((bucket) => {
return { ...bucket, type: STORAGE_ROW_TYPES.BUCKET, status: STORAGE_ROW_STATUS.READY }
})
this.buckets = formattedBuckets
return formattedBuckets
}
deleteBucket = async (bucket) => {
// Deleting a bucket requires the bucket to be empty first
// hence delete bucket and empty bucket are coupled tightly here
const { id, name: bucketName } = bucket
const emptyBucketRes = await post(`${this.endpoint}/buckets/${id}/empty`, {})
if (emptyBucketRes.error) {
this.ui.setNotification({ category: 'error', message: emptyBucketRes.error.message })
return false
}
const deleteBucketRes = await delete_(`${this.endpoint}/buckets/${id}`)
if (deleteBucketRes.error) {
this.ui.setNotification({ category: 'error', message: deleteBucketRes.error.message })
return false
}
await this.fetchBuckets()
if (bucketName === this.selectedBucket.name) {
this.setSelectedBucket({})
this.clearColumns()
this.clearOpenedFolders()
}
this.clearSelectedItemsToDelete()
this.closeDeleteBucketModal()
return true
}
editBucket = async (bucket, payload) => {
const res = await patch(`${this.endpoint}/buckets/${bucket.id}`, payload)
if (res.error) {
this.ui.setNotification({ category: 'error', message: res.error.message })
return res
}
this.openBucket({ ...bucket, ...payload })
this.fetchBuckets()
this.clearFilePreviewCache()
return res
}
/* Files CRUD */
getFile = async (fileEntry) => {

View File

@@ -1,30 +1,24 @@
import React, { useEffect } from 'react'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { find } from 'lodash'
import { observer } from 'mobx-react-lite'
import { useEffect } from 'react'
import { API_URL } from 'lib/constants'
import { useParams } from 'common'
import { StorageLayout } from 'components/layouts'
import { StorageExplorer } from 'components/to-be-cleaned/Storage'
import { useBucketsQuery } from 'data/storage/buckets-query'
import { useFlag, useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { PROJECT_STATUS } from 'lib/constants'
import { StorageLayout } from 'components/layouts'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { StorageExplorer } from 'components/to-be-cleaned/Storage'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { NextPageWithLayout } from 'types'
/**
* PageLayout is used to setup layout - as usual it will requires inject global store
*/
const PageLayout: NextPageWithLayout = () => {
const router = useRouter()
const { ref, bucketId } = router.query
const { ref, bucketId } = useParams()
const { ui } = useStore()
const project = ui.selectedProject
const storageStore = useStorageStore()
const { buckets, loaded } = storageStore
const { data, isSuccess } = useBucketsQuery({ projectRef: ref })
const buckets = data ?? []
const kpsEnabled = useFlag('initWithKps')
@@ -40,7 +34,7 @@ const PageLayout: NextPageWithLayout = () => {
return (
<div className="storage-container flex flex-grow p-4">
{loaded ? (
{isSuccess ? (
!bucket ? (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-scale-1100">Bucket {bucketId} cannot be found</p>

View File

@@ -1,18 +1,13 @@
import React, { useEffect } from 'react'
import { observer } from 'mobx-react-lite'
import { API_URL } from 'lib/constants'
import { useFlag, useStore } from 'hooks'
import { useParams } from 'common/hooks'
import { post } from 'lib/common/fetch'
import { PROJECT_STATUS } from 'lib/constants'
import { StorageLayout } from 'components/layouts'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { useFlag, useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { observer } from 'mobx-react-lite'
import { useEffect } from 'react'
import { NextPageWithLayout } from 'types'
/**
* PageLayout is used to setup layout - as usual it will requires inject global store
*/
const PageLayout: NextPageWithLayout = ({}) => {
const { ref } = useParams()
const { ui } = useStore()

View File

@@ -0,0 +1,22 @@
import { sanitizeRoute } from 'components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown'
test('Should sanitize project routes correctly when switching projects by removing project specific parameters', () => {
expect(sanitizeRoute('/project/[ref]', { ref: 'abc' })).toBe('/project/[ref]')
expect(sanitizeRoute('/project/[ref]/editor', { ref: 'abc' })).toBe('/project/[ref]/editor')
expect(sanitizeRoute('/project/[ref]/storage/buckets', { ref: 'abc' })).toBe(
'/project/[ref]/storage/buckets'
)
expect(sanitizeRoute('/project/[ref]/settings/billing/subscription', { ref: 'abc' })).toBe(
'/project/[ref]/settings/billing/subscription'
)
expect(sanitizeRoute('/project/[ref]/editor/[tableId]', { ref: 'abc', tableId: '10' })).toBe(
'/project/[ref]/editor'
)
expect(
sanitizeRoute('/project/[ref]/storage/buckets/[bucketId]', { ref: 'abc', bucketId: 'bucket-1' })
).toBe('/project/[ref]/storage/buckets')
expect(sanitizeRoute('/project/[ref]/logs/explorer?q=select', { ref: 'abc' })).toBe(
'/project/[ref]/logs/explorer?q=select'
)
})