From eabfebf0f777a561f21e26006c9beb151cefe6ff Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 10 Nov 2025 15:28:08 +0800 Subject: [PATCH] Refactor/use text confirm modal for delete buckets (#40291) * Refactor DeleteBucketModal to use TextConfirmModal * Refactor DeleteAnalyticsBucket to use TextConfirmModal * Update TextConfirmModal to disable button until text match * Refactor DeleteVectorBucketModal to use TextConfirmModal * Remove test case - no longer valid --- .../SimpleConfigurationDetails.tsx | 2 + .../DeleteAnalyticsBucketModal.tsx | 121 +++------------- .../interfaces/Storage/DeleteBucketModal.tsx | 125 ++++------------ .../VectorBuckets/DeleteVectorBucketModal.tsx | 133 ++++-------------- .../__tests__/DeleteBucketModal.test.tsx | 19 --- .../src/Dialogs/TextConfirmModal.tsx | 6 +- 6 files changed, 78 insertions(+), 328 deletions(-) diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx index 24b069cb8a1..4235fbaa115 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx @@ -23,6 +23,8 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketName }) const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) + if (!wrapperInstance) return null + return ( diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx index 0568c24ceb0..dc91ae53721 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/DeleteAnalyticsBucketModal.tsx @@ -1,27 +1,9 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' import { useParams } from 'common' import { useAnalyticsBucketDeleteMutation } from 'data/storage/analytics-bucket-delete-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - Input_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' import { useAnalyticsBucketAssociatedEntities, useAnalyticsBucketDeleteCleanUp, @@ -34,10 +16,6 @@ export interface DeleteAnalyticsBucketModalProps { onSuccess?: () => void } -const formId = `delete-analytics-bucket-form` - -// [Joshen] Can refactor to use TextConfirmModal - export const DeleteAnalyticsBucketModal = ({ visible, bucketId, @@ -47,16 +25,6 @@ export const DeleteAnalyticsBucketModal = ({ const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const schema = z.object({ - confirm: z.literal(bucketId, { - errorMap: () => ({ message: `Please enter "${bucketId}" to confirm` }), - }), - }) - - const form = useForm>({ - resolver: zodResolver(schema), - }) - const { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } = useAnalyticsBucketAssociatedEntities({ projectRef, bucketId: bucketId }) @@ -83,7 +51,7 @@ export const DeleteAnalyticsBucketModal = ({ }, }) - const onSubmit: SubmitHandler> = async () => { + const onConfirmDelete = async () => { if (!projectRef) return console.error('Project ref is required') if (!bucketId) return console.error('No bucket is selected') deleteAnalyticsBucket({ projectRef, id: bucketId }) @@ -92,73 +60,26 @@ export const DeleteAnalyticsBucketModal = ({ const isDeleting = isDeletingAnalyticsBucket || isCleaningUpAnalyticsBucket return ( - { - if (!open) onClose() + - - - Confirm deletion of {bucketId} - - - - - - - -

- Your bucket {bucketId} and all its - contents will be permanently deleted. -

-
- - - -
- ( - - Type {bucketId} to - confirm. - - } - > - - - - - )} - /> - -
-
- - - - -
-
+

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

+ ) } diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index c75c54bc234..206489e8919 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -1,58 +1,27 @@ -import { zodResolver } from '@hookform/resolvers/zod' import { get as _get, find } from 'lodash' import { useRouter } from 'next/router' -import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' import { useParams } from 'common' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation' -import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' import { useBucketDeleteMutation } from 'data/storage/bucket-delete-mutation' import { Bucket, useBucketsQuery } from 'data/storage/buckets-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - Input_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { TextConfirmModal } from 'ui-patterns/Dialogs/TextConfirmModal' import { formatPoliciesForStorage } from './Storage.utils' export interface DeleteBucketModalProps { visible: boolean - bucket: Bucket | AnalyticsBucket + bucket: Bucket onClose: () => void } -const formId = `delete-storage-bucket-form` - export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModalProps) => { const router = useRouter() const { ref: projectRef, bucketId } = useParams() const { data: project } = useSelectedProjectQuery() - const schema = z.object({ - confirm: z.literal(bucket.id, { - errorMap: () => ({ message: `Please enter "${bucket.id}" to confirm` }), - }), - }) - - const form = useForm>({ - resolver: zodResolver(schema), - }) - const { data } = useBucketsQuery({ projectRef }) const buckets = data ?? [] @@ -62,7 +31,8 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa schema: 'storage', }) - const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation() + const { mutateAsync: deletePolicy, isLoading: isDeletingPolicies } = + useDatabasePolicyDeleteMutation() const { mutate: deleteBucket, isLoading: isDeletingBucket } = useBucketDeleteMutation({ onSuccess: async () => { @@ -102,80 +72,33 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa }, }) - const onSubmit: SubmitHandler> = async () => { + const onConfirmDelete = async () => { if (!projectRef) return console.error('Project ref is required') if (!bucket) return console.error('No bucket is selected') deleteBucket({ projectRef, id: bucket.id }) } return ( - { - if (!open) onClose() + - - - Confirm deletion of {bucket.id} - - - - - - - -

- Your bucket {bucket.id} and all its - contents will be permanently deleted. -

-
- - - -
- ( - - Type {bucket.id} to - confirm. - - } - > - - - - - )} - /> - -
-
- - - - -
-
+

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

+ ) } diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx index 9d66974412c..40db9b068ea 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx @@ -1,28 +1,10 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' import { useParams } from 'common' import { useVectorBucketDeleteMutation } from 'data/storage/vector-bucket-delete-mutation' import { deleteVectorBucketIndex } from 'data/storage/vector-bucket-index-delete-mutation' import { useVectorBucketsIndexesQuery } from 'data/storage/vector-buckets-indexes-query' -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - Input_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns/admonition' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' export interface DeleteVectorBucketModalProps { visible: boolean @@ -31,10 +13,6 @@ export interface DeleteVectorBucketModalProps { onSuccess: () => void } -const formId = `delete-storage-vector-bucket-form` - -// [Joshen] Can refactor to use TextConfirmModal - export const DeleteVectorBucketModal = ({ visible, bucketName, @@ -43,29 +21,20 @@ export const DeleteVectorBucketModal = ({ }: DeleteVectorBucketModalProps) => { const { ref: projectRef } = useParams() - const schema = z.object({ - confirm: z.literal(bucketName, { - errorMap: () => ({ message: `Please enter "${bucketName}" to confirm` }), - }), - }) - - const form = useForm>({ - resolver: zodResolver(schema), - }) - - const { mutate: deleteBucket, isLoading } = useVectorBucketDeleteMutation({ + const { mutate: deleteBucket, isLoading: isDeletingBucket } = useVectorBucketDeleteMutation({ onSuccess: async () => { toast.success(`Bucket "${bucketName}" deleted successfully`) onSuccess() }, }) - const { data: { indexes = [] } = {} } = useVectorBucketsIndexesQuery({ - projectRef, - vectorBucketName: bucketName, - }) + const { data: { indexes = [] } = {}, isLoading: isDeletingIndexes } = + useVectorBucketsIndexesQuery({ + projectRef, + vectorBucketName: bucketName, + }) - const onSubmit: SubmitHandler> = async () => { + const onConfirmDelete = async () => { if (!projectRef) return console.error('Project ref is required') if (!bucketName) return console.error('No bucket is selected') @@ -79,7 +48,6 @@ export const DeleteVectorBucketModal = ({ }) ) await Promise.all(promises) - deleteBucket({ projectRef, bucketName }) } catch (error) { toast.error( @@ -89,73 +57,26 @@ export const DeleteVectorBucketModal = ({ } return ( - { - if (!open) onCancel() + - - - Confirm deletion of {bucketName} - - - - - - - -

- Your bucket {bucketName} and all its - contents will be permanently deleted. -

-
- - - -
- ( - - Type {bucketName} to - confirm. - - } - > - - - - - )} - /> - -
-
- - - - -
-
+

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

+ ) } diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx index a119c6a7558..aa407b64a63 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -119,23 +119,4 @@ describe(`DeleteBucketModal`, () => { await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) expect(routerMock.asPath).toStrictEqual(`/project/default/storage/files`) }) - - 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/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx index 9c81a4c4d61..60cc6f2e74d 100644 --- a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx +++ b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx @@ -51,7 +51,7 @@ export interface TextConfirmModalProps { errorMessage?: string } -const TextConfirmModal = forwardRef< +export const TextConfirmModal = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & TextConfirmModalProps >( @@ -99,6 +99,8 @@ const TextConfirmModal = forwardRef< }, }) + const isFormValid = form.formState.isValid + // 2. Define a submit handler. function onSubmit(values: z.infer) { // Do something with the form values. @@ -197,7 +199,7 @@ const TextConfirmModal = forwardRef< } htmlType="submit" loading={loading} - disabled={loading} + disabled={!isFormValid || loading} className="truncate" > {confirmLabel}