diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx index 01849b8ae8..73167045ed 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx @@ -1,8 +1,10 @@ import { isEmpty, noop } from 'lodash' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { useFeaturePreviewModal } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import useLatest from 'hooks/misc/useLatest' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { Modal } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { POLICY_MODAL_VIEWS } from '../Policies.constants' @@ -71,22 +73,17 @@ const PolicyEditorModal = ({ ) const [policyStatementForReview, setPolicyStatementForReview] = useState('') const [isDirty, setIsDirty] = useState(false) - const [isClosingPolicyEditorModal, setIsClosingPolicyEditorModal] = useState(false) - useEffect(() => { - if (visible) { - if (isNewPolicy) { - onViewIntro() - } else { - onViewEditor() - } - setPolicyFormFields(initializedPolicyFormFields) - } - }, [visible]) - /* Methods that are for the UI */ + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose: () => { + onSelectCancel() + setIsDirty(false) + }, + }) - const onViewIntro = () => setView(POLICY_MODAL_VIEWS.SELECTION) - const onViewEditor = () => setView(POLICY_MODAL_VIEWS.EDITOR) + const onViewIntro = useCallback(() => setView(POLICY_MODAL_VIEWS.SELECTION), []) + const onViewEditor = useCallback(() => setView(POLICY_MODAL_VIEWS.EDITOR), []) const onViewTemplates = () => { setPreviousView(view) setView(POLICY_MODAL_VIEWS.TEMPLATES) @@ -94,6 +91,28 @@ const PolicyEditorModal = ({ const onReviewPolicy = () => setView(POLICY_MODAL_VIEWS.REVIEW) const onSelectBackFromTemplates = () => setView(previousView) + const isNewPolicyRef = useLatest(isNewPolicy) + const initializedPolicyFormFieldsRef = useLatest(initializedPolicyFormFields) + useEffect(() => { + if (visible) { + if (isNewPolicyRef.current) { + onViewIntro() + } else { + onViewEditor() + } + setPolicyFormFields(initializedPolicyFormFieldsRef.current) + } + }, [ + onViewIntro, + onViewEditor, + isNewPolicyRef, + initializedPolicyFormFieldsRef, + // end of stable references + visible, + ]) + + /* Methods that are for the UI */ + const onToggleFeaturePreviewModal = () => { toggleFeaturePreviewModal() onSelectCancel() @@ -158,10 +177,6 @@ const PolicyEditorModal = ({ hasError ? onViewEditor() : onSaveSuccess() } - const isClosingPolicyEditor = () => { - isDirty ? setIsClosingPolicyEditorModal(true) : onSelectCancel() - } - return ( , ]} - onCancel={isClosingPolicyEditor} + onCancel={confirmOnClose} >
- setIsClosingPolicyEditorModal(false)} - onConfirm={() => { - onSelectCancel() - setIsClosingPolicyEditorModal(false) - setIsDirty(false) - }} - > -

- There are unsaved changes. Are you sure you want to close the editor? Your changes will - be lost. -

-
+ {view === POLICY_MODAL_VIEWS.SELECTION ? ( ( + +

+ There are unsaved changes. Are you sure you want to close the editor? Your changes will be + lost. +

+
+) + export default PolicyEditorModal diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx index 232c74dd81..5f99b384f8 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx @@ -4,7 +4,7 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { isEqual } from 'lodash' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' @@ -17,6 +17,7 @@ import { databasePoliciesKeys } from 'data/database-policies/keys' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { Button, Checkbox_Shadcn_, @@ -94,7 +95,6 @@ export const PolicyEditorPanel = memo(function ({ const [selectedDiff, setSelectedDiff] = useState() const [showTools, setShowTools] = useState(false) - const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState(false) const formId = 'rls-editor' const FormSchema = z.object({ @@ -139,7 +139,7 @@ export const PolicyEditorPanel = memo(function ({ }, }) - const onClosingPanel = () => { + const hasUnsavedChanges = useCallback(() => { const editorOneValue = editorOneRef.current?.getValue().trim() ?? null const editorOneFormattedValue = !editorOneValue ? null : editorOneValue const editorTwoValue = editorTwoRef.current?.getValue().trim() ?? null @@ -147,7 +147,10 @@ export const PolicyEditorPanel = memo(function ({ const policyCreateUnsaved = selectedPolicy === undefined && - (name.length > 0 || roles.length > 0 || editorOneFormattedValue || editorTwoFormattedValue) + (name.length > 0 || + roles.length > 0 || + !!editorOneFormattedValue || + !!editorTwoFormattedValue) const policyUpdateUnsaved = selectedPolicy !== undefined ? checkIfPolicyHasChanged(selectedPolicy, { @@ -158,12 +161,13 @@ export const PolicyEditorPanel = memo(function ({ }) : false - if (policyCreateUnsaved || policyUpdateUnsaved) { - setIsClosingPolicyEditorPanel(true) - } else { - onSelectCancel() - } - } + return policyCreateUnsaved || policyUpdateUnsaved + }, [command, name, roles, selectedPolicy]) + + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: hasUnsavedChanges, + onClose: onSelectCancel, + }) const onSubmit = (data: z.infer) => { const { name, table, behavior, command, roles } = data @@ -242,7 +246,6 @@ export const PolicyEditorPanel = memo(function ({ editorOneRef.current?.setValue('') editorTwoRef.current?.setValue('') setShowTools(false) - setIsClosingPolicyEditorPanel(false) setError(undefined) setShowDetails(false) setSelectedDiff(undefined) @@ -288,7 +291,7 @@ export const PolicyEditorPanel = memo(function ({ <>
- onClosingPanel()}> + onClosingPanel()} + onClick={confirmOnClose} > Cancel @@ -569,23 +572,24 @@ export const PolicyEditorPanel = memo(function ({
- setIsClosingPolicyEditorPanel(false)} - onConfirm={() => { - onSelectCancel() - setIsClosingPolicyEditorPanel(false) - }} - > -

- Are you sure you want to close the editor? Any unsaved changes on your policy and - conversations with the Assistant will be lost. -

-
+ ) }) PolicyEditorPanel.displayName = 'PolicyEditorPanel' + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ Are you sure you want to close the editor? Any unsaved changes on your policy and + conversations with the Assistant will be lost. +

+
+) diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index a52e5f3089..94b78e8a2b 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -13,6 +13,7 @@ import { useDatabaseFunctionCreateMutation } from 'data/database-functions/datab import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import type { FormSchema } from 'types' import { @@ -76,7 +77,6 @@ export const CreateFunction = ({ onClose, }: CreateFunctionProps) => { const { data: project } = useSelectedProjectQuery() - const [isClosingPanel, setIsClosingPanel] = useState(false) const [advancedSettingsShown, setAdvancedSettingsShown] = useState(false) const [focusedEditor, setFocusedEditor] = useState(false) @@ -87,15 +87,16 @@ export const CreateFunction = ({ }) const language = form.watch('language') + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: () => form.formState.isDirty, + onClose, + }) + const { mutate: createDatabaseFunction, isLoading: isCreating } = useDatabaseFunctionCreateMutation() const { mutate: updateDatabaseFunction, isLoading: isUpdating } = useDatabaseFunctionUpdateMutation() - function isClosingSidePanel() { - form.formState.isDirty ? setIsClosingPanel(true) : onClose() - } - const onSubmit: SubmitHandler> = async (data) => { if (!project) return console.error('Project is required') const payload = { @@ -156,7 +157,7 @@ export const CreateFunction = ({ const { data: protectedSchemas } = useProtectedSchemas() return ( - isClosingSidePanel()}> + -
- setIsClosingPanel(false)} - onConfirm={() => { - setIsClosingPanel(false) - onClose() - }} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will - be lost. -

-
+ ) } +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) + interface FormFieldConfigParamsProps { readonly?: boolean } diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx index 24e50fb1fc..479c4e9604 100644 --- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -9,6 +9,7 @@ import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/databas import { getTableEditor } from 'data/table-editor/table-editor-query' import { useTablesQuery } from 'data/tables/tables-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { isValidHttpUrl, uuidv4 } from 'lib/helpers' import { Button, Form, SidePanel } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -38,7 +39,6 @@ export const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelP const { ref } = useParams() const submitRef = useRef(null) const [isEdited, setIsEdited] = useState(false) - const [isClosingPanel, setIsClosingPanel] = useState(false) // [Joshen] There seems to be some bug between Checkbox.Group within the Form component // hence why this external state as a temporary workaround @@ -135,11 +135,6 @@ export const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelP timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000), } - const onClosePanel = () => { - if (isEdited) setIsClosingPanel(true) - else onClose() - } - const onUpdateSelectedEvents = (event: string) => { if (events.includes(event)) { setEvents(events.filter((e) => e !== event)) @@ -261,10 +256,17 @@ export const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelP useEffect(() => { if (visible) { setIsEdited(false) - setIsClosingPanel(false) } }, [visible]) + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: () => isEdited, + onClose: () => { + setIsEdited(false) + onClose() + }, + }) + return ( <> {}} - onCancel={() => onClosePanel()} + onCancel={confirmOnClose} customFooter={
@@ -480,21 +481,7 @@ export const TriggerSheet = ({ - setIsClosingPanel(false)} - onConfirm={() => { - setIsClosingPanel(false) - onClose() - }} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will - be lost. -

-
+ @@ -509,3 +496,18 @@ export const TriggerSheet = ({ ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx index aec6bc3039..fe0f367da0 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { SubmitHandler, useForm, type UseFormReturn } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' @@ -7,6 +7,7 @@ import z from 'zod' import { useParams } from 'common' import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation' import { ProjectSecret } from 'data/secrets/secrets-query' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { Eye, EyeOff, X } from 'lucide-react' import { useLatest } from 'react-use' import { @@ -73,7 +74,7 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro }) } - const { confirmOnClose, modal: closeConfirmationModal } = useConfirmOnClose({ + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, onClose, }) @@ -97,7 +98,7 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro - {closeConfirmationModal} + ) } @@ -211,58 +212,11 @@ const SecretField = ({ form }: SecretFieldProps): ReactNode => { ) } -type UseConfirmOnCloseParams = { - checkIsDirty: () => boolean - onClose: () => void -} - -type ConfirmOnCloseReturn = { - confirmOnClose: () => void - modal: ReactNode -} - -const useConfirmOnClose = ({ - checkIsDirty, - onClose, -}: UseConfirmOnCloseParams): ConfirmOnCloseReturn => { - const [visible, setVisible] = useState(false) - - const confirmOnClose = useCallback(() => { - if (checkIsDirty()) { - setVisible(true) - } else { - onClose() - } - }, [checkIsDirty, onClose]) - - const onConfirm = () => { - setVisible(false) - onClose() - } - - return { - confirmOnClose, - modal: ( - setVisible(false)} - /> - ), - } -} - -type CloseConfirmationModalProps = { - visible: boolean - onClose: () => void - onCancel: () => void -} - const CloseConfirmationModal = ({ visible, onClose, onCancel, -}: CloseConfirmationModalProps): ReactNode => { +}: ConfirmOnCloseModalProps): ReactNode => { return ( supportsSeconds: boolean - isClosing: boolean - setIsClosing: (v: boolean) => void + onDirty: (isDirty: boolean) => void onClose: () => void + onCloseWithConfirmation: () => void } const FORM_ID = 'create-cron-job-sidepanel' @@ -87,9 +86,9 @@ const buildCommand = (values: CronJobType) => { export const CreateCronJobSheet = ({ selectedCronJob, supportsSeconds, - isClosing, - setIsClosing, + onDirty, onClose, + onCloseWithConfirmation: confirmOnClose, }: CreateCronJobSheetProps) => { const { childId } = useParams() const { data: project } = useSelectedProjectQuery() @@ -129,17 +128,14 @@ export const CreateCronJobSheet = ({ }, }) - const isEdited = form.formState.isDirty - // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet - if (!isEdited && isClosing) onClose() + useEffect(() => { + const subscription = form.watch(() => { + const isDirty = form.formState.isDirty + onDirty(isDirty) + }) - const onClosePanel = () => { - if (isEdited) { - setIsClosing(true) - } else { - onClose() - } - } + return () => subscription.unsubscribe() + }, [form, onDirty]) const [ cronType, @@ -430,7 +426,7 @@ export const CreateCronJobSheet = ({ size="tiny" type="default" htmlType="button" - onClick={onClosePanel} + onClick={confirmOnClose} disabled={isLoading} > Cancel @@ -447,18 +443,6 @@ export const CreateCronJobSheet = ({
- setIsClosing(false)} - onConfirm={() => onClose()} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
{pgNetExtension && ( { const { data: project } = useSelectedProjectQuery() const [isEditSheetOpen, setIsEditSheetOpen] = useState(false) - const [isClosing, setIsClosing] = useState(false) const jobId = Number(childId) @@ -50,6 +51,15 @@ export const CronJobPage = () => { const edgeFunctionSlug = edgeFunction?.split('/functions/v1/').pop() const isValidEdgeFunction = edgeFunctions.some((x) => x.slug === edgeFunctionSlug) + const [isDirty, setIsDirty] = useState(false) + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose: () => { + setIsDirty(false) + setIsEditSheetOpen(false) + }, + }) + const breadcrumbItems = [ { label: 'Integrations', @@ -177,16 +187,29 @@ export const CronJobPage = () => { command: job.command, }} supportsSeconds={true} - isClosing={isClosing} - setIsClosing={setIsClosing} - onClose={() => { - setIsEditSheetOpen(false) - setIsClosing(false) - }} + onDirty={setIsDirty} + onClose={() => setIsEditSheetOpen(false)} + onCloseWithConfirmation={confirmOnClose} /> )} + ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 8a55a9b29e..4c0f88b68b 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -17,10 +17,12 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { BASE_PATH } from 'lib/constants' import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { formatCronJobColumns } from './CronJobs.utils' import { DeleteCronJob } from './DeleteCronJob' @@ -42,8 +44,6 @@ export const CronjobsTab = () => { parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true }) ) - // used for confirmation prompt in the Create Cron Job Sheet - const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false) const [cronJobForEditing, setCronJobForEditing] = useState< Pick | undefined >() @@ -131,6 +131,20 @@ export const CronjobsTab = () => { setCreateCronJobSheetShown(true) } + const [isDirty, setIsDirty] = useState(false) + const onClose = () => { + setCronJobForEditing(undefined) + setCreateCronJobSheetShown(false) + cleanPointerEventsNoneOnBody(500) + } + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose: () => { + setIsDirty(false) + onClose() + }, + }) + return ( <>
@@ -267,25 +281,33 @@ export const CronjobsTab = () => { cronJob={cronJobForDeletion!} /> - setIsClosingCreateCronJobSheet(true)} - > + { - setIsClosingCreateCronJobSheet(false) - setCronJobForEditing(undefined) - setCreateCronJobSheetShown(false) - cleanPointerEventsNoneOnBody(500) - }} - isClosing={isClosingCreateCronJobSheet} - setIsClosing={setIsClosingCreateCronJobSheet} + onDirty={setIsDirty} + onClose={onClose} + onCloseWithConfirmation={confirmOnClose} /> + ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx index ee5a1e4457..25cabbfd23 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx @@ -8,7 +8,9 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation' import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { useRouter } from 'next/router' +import { useEffect } from 'react' import { Badge, Button, @@ -21,6 +23,8 @@ import { RadioGroupStacked, RadioGroupStackedItem, Separator, + Sheet, + SheetContent, SheetFooter, SheetHeader, SheetSection, @@ -33,8 +37,7 @@ import { QUEUE_TYPES } from './Queues.constants' import { QueryNameSchema } from './Queues.utils' export interface CreateQueueSheetProps { - isClosing: boolean - setIsClosing: (v: boolean) => void + visible: boolean onClose: () => void } @@ -67,13 +70,7 @@ export type QueueType = CreateQueueForm['values'] const FORM_ID = 'create-queue-sidepanel' -export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => { - // This is for enabling pg_partman extension which will be used for partitioned queues (3rd kind of queue) - // const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) - // const { can: canToggleExtensions } = useAsyncCheckPermissions( - // PermissionAction.TENANT_SQL_ADMIN_WRITE, - // 'extensions' - // ) +export const CreateQueueSheet = ({ visible, onClose }: CreateQueueSheetProps) => { const router = useRouter() const { data: project } = useSelectedProjectQuery() @@ -93,18 +90,22 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue }, }) - const isEdited = form.formState.isDirty - - // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet - if (!isEdited && isClosing) onClose() - - const onClosePanel = () => { - if (isEdited) { - setIsClosing(true) - } else { - onClose() + useEffect(() => { + if (visible) { + form.reset() } - } + }, [ + form, + // end of stable references + visible, + ]) + + const checkIsDirty = () => form.formState.isDirty + + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty, + onClose, + }) const onSubmit: SubmitHandler = async ({ name, enableRls, values }) => { createQueue( @@ -143,250 +144,208 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue const queueType = form.watch('values.type') return ( - <> -
- - Create a new queue - + + +
+ + Create a new queue + -
- -
- - ( - - - - - - Must be all lowercase letters - - - )} - /> - - - - ( - - - - {QUEUE_TYPES.map((definition) => ( - -
-
{definition.icon}
-
-
-

{definition.label}

- {definition.value === 'partitioned' && ( - Coming soon - )} -
-

- {definition.description} -

-
-
- {/* {!pgPartmanExtensionInstalled && - definition.value === 'partitioned' ? ( -
- - - pg_partman needs to be installed to use this type - -
- ) : null} */} -
- ))} -
-
-
- )} - /> - {/* {!pgPartmanExtensionInstalled && ( - - Enable pg_partman for partitioned - queues - - } - description={ -
- - This will allow you to create partitioned queues which can handle a large - amount of messages +
+ + + + ( + + + + + + Must be all lowercase letters - setShowEnableExtensionModal(true)} - tooltip={{ - content: { - side: 'bottom', - text: 'You need additional permissions to enable database extensions', - }, - }} - > - Install pg_partman extension - -
- } + + )} /> - )} */} - - - {queueType === 'partitioned' && ( - <> - - ( - - ms

} - /> -
- )} - /> - ( - - ms

} - /> -
- )} - /> -
- - - )} - - ( - -

Enable Row Level Security (RLS)

- Recommended -
- } - description="Restrict access to your queue by enabling RLS and writing Postgres policies to control access for each role." - > - - - - - )} - /> - {!isExposed ? ( - - - - ) : ( - + + + ( + + + + {QUEUE_TYPES.map((definition) => ( + +
+
{definition.icon}
+
+
+

+ {definition.label} +

+ {definition.value === 'partitioned' && ( + Coming soon + )} +
+

+ {definition.description} +

+
+
+
+ ))} +
+
+
+ )} /> +
+ + {queueType === 'partitioned' && ( + <> + + ( + + ms

} + /> +
+ )} + /> + ( + + ms

} + /> +
+ )} + /> +
+ + )} -
- -
+ + ( + +

Enable Row Level Security (RLS)

+ Recommended +
+ } + description="Restrict access to your queue by enabling RLS and writing Postgres policies to control access for each role." + > + + + + + )} + /> + {!isExposed ? ( + + + + ) : ( + + )} + + + +
+ + + +
- - - - -
- setIsClosing(false)} - onConfirm={() => onClose()} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
- {/* {pgPartmanExtension && ( - setShowEnableExtensionModal(false)} - /> - )} */} - + + + ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx index 723e1c64de..afb326472e 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx @@ -9,7 +9,7 @@ import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useQueuesQuery } from 'data/database-queues/database-queues-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui' +import { Button, cn, LoadingLine } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { CreateQueueSheet } from './CreateQueueSheet' import { formatQueueColumns, prepareQueuesForDataGrid } from './Queues.utils' @@ -22,8 +22,6 @@ export const QueuesTab = () => { const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')) const [search, setSearch] = useState(searchQuery) - // used for confirmation prompt in the Create Queue Sheet - const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false) const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false) const { @@ -162,18 +160,12 @@ export const QueuesTab = () => { - setIsClosingCreateQueueSheet(true)}> - - { - setIsClosingCreateQueueSheet(false) - setCreateQueueSheetShown(false) - }} - isClosing={isClosingCreateQueueSheet} - setIsClosing={setIsClosingCreateQueueSheet} - /> - - + { + setCreateQueueSheetShown(false) + }} + /> ) } diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx index ab68c698cb..8b42c5d232 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx @@ -20,7 +20,6 @@ import { SheetHeader, SheetTitle, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { CreateWrapperSheetProps } from './CreateWrapperSheet' import InputField from './InputField' import { makeValidateRequired } from './Wrappers.utils' @@ -58,9 +57,9 @@ type Target = 'S3Tables' | 'R2Catalog' | 'IcebergRestCatalog' export const CreateIcebergWrapperSheet = ({ wrapperMeta: wrapperMetaOriginal, - isClosing, - setIsClosing, + onDirty, onClose, + onCloseWithConfirmation, }: CreateWrapperSheetProps) => { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() @@ -187,21 +186,9 @@ export const CreateIcebergWrapperSheet = ({ onSubmit={onSubmit} className="flex-grow flex flex-col h-full" > - {({ values, initialValues, setFieldValue }: any) => { + {({ values, initialValues }: any) => { const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) - - const onClosePanel = () => { - if (hasChanges) { - setIsClosing(true) - } else { - onClose() - } - } - - // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet - if (!hasChanges && isClosing) { - onClose() - } + onDirty(hasChanges) return ( <> @@ -349,7 +336,7 @@ export const CreateIcebergWrapperSheet = ({ size="tiny" type="default" htmlType="button" - onClick={onClosePanel} + onClick={onCloseWithConfirmation} disabled={isLoading} > Cancel @@ -369,18 +356,6 @@ export const CreateIcebergWrapperSheet = ({ }} - setIsClosing(false)} - onConfirm={() => onClose()} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
) } diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index e3dcb01cd3..1b613018ff 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -24,7 +24,6 @@ import { SheetTitle, WarningIcon, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import InputField from './InputField' import { WrapperMeta } from './Wrappers.types' import { makeValidateRequired } from './Wrappers.utils' @@ -33,17 +32,17 @@ import WrapperTableEditor from './WrapperTableEditor' const FORM_ID = 'create-wrapper-form' export interface CreateWrapperSheetProps { - isClosing: boolean wrapperMeta: WrapperMeta - setIsClosing: (v: boolean) => void + onDirty: (isDirty: boolean) => void onClose: () => void + onCloseWithConfirmation: () => void } export const CreateWrapperSheet = ({ wrapperMeta, - isClosing, - setIsClosing, + onDirty, onClose, + onCloseWithConfirmation, }: CreateWrapperSheetProps) => { const queryClient = useQueryClient() @@ -53,7 +52,6 @@ export const CreateWrapperSheet = ({ const [newTables, setNewTables] = useState([]) const [isEditingTable, setIsEditingTable] = useState(false) - const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false) const [selectedTableToEdit, setSelectedTableToEdit] = useState() const [selectedMode, setSelectedMode] = useState<'tables' | 'schema'>( wrapperMeta.tables.length > 0 ? 'tables' : 'schema' @@ -197,21 +195,9 @@ export const CreateWrapperSheet = ({ onSubmit={onSubmit} className="flex-grow flex flex-col h-full" > - {({ values, initialValues, setFieldValue }: any) => { + {({ values, initialValues }: any) => { const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) - - const onClosePanel = () => { - if (hasChanges) { - setIsClosing(true) - } else { - onClose() - } - } - - // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet - if (!hasChanges && isClosing) { - onClose() - } + onDirty(hasChanges) return ( <> @@ -453,7 +439,7 @@ export const CreateWrapperSheet = ({ size="tiny" type="default" htmlType="button" - onClick={onClosePanel} + onClick={onCloseWithConfirmation} disabled={isLoading} > Cancel @@ -473,18 +459,6 @@ export const CreateWrapperSheet = ({ }} - setIsClosing(false)} - onConfirm={() => onClose()} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
({}) + const hasChangesRef = useRef(false) const initialValues = { wrapper_name: wrapper?.name, @@ -109,6 +111,23 @@ export const EditWrapperSheet = ({ }) } + const checkIsDirty = useCallback(() => hasChangesRef.current, []) + + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty, + onClose, + }) + + useEffect(() => { + if (!isClosing) return + if (checkIsDirty()) { + confirmOnClose() + } else { + onClose() + } + setIsClosing(false) + }, [checkIsDirty, confirmOnClose, isClosing, onClose, setIsClosing]) + return ( <>
@@ -131,22 +150,10 @@ export const EditWrapperSheet = ({ const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues) const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables) const hasChanges = hasFormChanges || hasTableChanges + hasChangesRef.current = hasChanges const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted) - const onClosePanel = () => { - if (hasChanges) { - setIsClosing(true) - } else { - onClose() - } - } - - // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet - if (!hasChanges && isClosing) { - onClose() - } - // [Alaister] although this "technically" is breaking the rules of React hooks // it won't error because the hooks are always rendered in the same order // eslint-disable-next-line react-hooks/rules-of-hooks @@ -321,7 +328,7 @@ export const EditWrapperSheet = ({ size="tiny" type="default" htmlType="button" - onClick={onClosePanel} + onClick={confirmOnClose} disabled={isSaving} > Cancel @@ -343,18 +350,7 @@ export const EditWrapperSheet = ({
- setIsClosing(false)} - onConfirm={() => onClose()} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
+ ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx index 14ccc25b99..04f55c7042 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -7,6 +7,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -17,6 +18,7 @@ import { SheetContent, WarningIcon, } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet' import { CreateWrapperSheet } from './CreateWrapperSheet' @@ -27,7 +29,6 @@ export const WrapperOverviewTab = () => { const { id } = useParams() const { data: project } = useSelectedProjectQuery() const [createWrapperShown, setCreateWrapperShown] = useState(false) - const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) const { can: canCreateWrapper } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, @@ -39,6 +40,15 @@ export const WrapperOverviewTab = () => { connectionString: project?.connectionString, }) + const [isDirty, setIsDirty] = useState(false) + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose: () => { + setCreateWrapperShown(false) + setIsDirty(false) + }, + }) + const wrapperMeta = WRAPPERS.find((w) => w.name === id) if (!wrapperMeta) { @@ -127,19 +137,34 @@ export const WrapperOverviewTab = () => { - setisClosingCreateWrapper(true)}> + setIsDirty(dirty)} onClose={() => { setCreateWrapperShown(false) - setisClosingCreateWrapper(false) }} - isClosing={isClosingCreateWrapper} - setIsClosing={setisClosingCreateWrapper} + onCloseWithConfirmation={confirmOnClose} /> + ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx index 3c73661275..d3e7f9e556 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -6,7 +6,9 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FDW, useFDWsQuery } from 'data/fdw/fdws-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { Sheet, SheetContent } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { CreateWrapperSheet } from './CreateWrapperSheet' import DeleteWrapperModal from './DeleteWrapperModal' import { WRAPPERS } from './Wrappers.constants' @@ -18,7 +20,6 @@ export const WrappersTab = () => { const { data: project } = useSelectedProjectQuery() const [selectedWrapperForDelete, setSelectedWrapperForDelete] = useState(null) const [createWrapperShown, setCreateWrapperShown] = useState(false) - const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) const { can: canCreateWrapper } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, @@ -38,28 +39,34 @@ export const WrappersTab = () => { ? wrappers.filter((w) => wrapperMetaComparator(wrapperMeta, w)) : [] + const [isDirty, setIsDirty] = useState(false) + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: useCallback(() => isDirty, [isDirty]), + onClose: useCallback(() => { + setCreateWrapperShown(false) + setIsDirty(false) + }, []), + }) + const Container = useCallback( ({ ...props }: { children: ReactNode } & HTMLProps) => (
{props.children} - setisClosingCreateWrapper(true)}> + {wrapperMeta && ( { - setCreateWrapperShown(false) - setisClosingCreateWrapper(false) - }} - isClosing={isClosingCreateWrapper} - setIsClosing={setisClosingCreateWrapper} + onDirty={setIsDirty} + onClose={() => setCreateWrapperShown(false)} + onCloseWithConfirmation={confirmOnClose} /> )}
), - [createWrapperShown, wrapperMeta, isClosingCreateWrapper] + [createWrapperShown, wrapperMeta, confirmOnClose] ) if (!wrapperMeta) { @@ -103,6 +110,22 @@ export const WrappersTab = () => { onClose={() => setSelectedWrapperForDelete(null)} /> )} + ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index f4a2feeb49..1bd5e8c428 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -23,6 +23,7 @@ import { RetrieveTableResult } from 'data/tables/table-retrieve-query' import { getTables } from 'data/tables/tables-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { useUrlState } from 'hooks/ui/useUrlState' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' @@ -133,7 +134,14 @@ export const SidePanelEditor = ({ const { data: org } = useSelectedOrganizationQuery() const [isEdited, setIsEdited] = useState(false) - const [isClosingPanel, setIsClosingPanel] = useState(false) + + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + checkIsDirty: () => isEdited, + onClose: () => { + setIsEdited(false) + snap.closeSidePanel() + }, + }) const enumArrayColumns = (selectedTable?.columns ?? []) .filter((column) => { @@ -632,13 +640,7 @@ export const SidePanelEditor = ({ snap.closeSidePanel() } - const onClosePanel = () => { - if (isEdited) { - setIsClosingPanel(true) - } else { - snap.closeSidePanel() - } - } + const onClosePanel = confirmOnClose return ( <> @@ -730,22 +732,22 @@ export const SidePanelEditor = ({ closePanel={onClosePanel} updateEditorDirty={setIsEdited} /> - setIsClosingPanel(false)} - onConfirm={() => { - setIsClosingPanel(false) - setIsEdited(false) - snap.closeSidePanel() - }} - > -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
+ ) } + +const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+) diff --git a/apps/studio/hooks/ui/useConfirmOnClose.tsx b/apps/studio/hooks/ui/useConfirmOnClose.tsx new file mode 100644 index 0000000000..b87b053197 --- /dev/null +++ b/apps/studio/hooks/ui/useConfirmOnClose.tsx @@ -0,0 +1,56 @@ +import { useCallback, useMemo, useState } from 'react' +import useLatest from '../misc/useLatest' + +export interface ConfirmOnCloseModalProps { + visible: boolean + onClose: () => void + onCancel: () => void +} + +interface UseConfirmOnCloseProps { + checkIsDirty: () => boolean + onClose: () => void +} + +export const useConfirmOnClose = ({ checkIsDirty, onClose }: UseConfirmOnCloseProps) => { + const [visible, setVisible] = useState(false) + + const checkIsDirtyRef = useLatest(checkIsDirty) + const onCloseRef = useLatest(onClose) + + const confirmOnClose = useCallback(() => { + if (checkIsDirtyRef.current()) { + setVisible(true) + } else { + onCloseRef.current() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const onConfirm = useCallback(() => { + setVisible(false) + onCloseRef.current() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const onCancel = useCallback(() => { + setVisible(false) + }, []) + + const modalProps: ConfirmOnCloseModalProps = useMemo( + () => ({ + visible, + onClose: onConfirm, + onCancel, + }), + [visible, onConfirm, onCancel] + ) + + return useMemo( + () => ({ + confirmOnClose, + modalProps, + }), + [confirmOnClose, modalProps] + ) +}