refactor(close confirmation modal): abstract out useConfirmOnClose hook (#40310)

Abstract out a hook, useConfirmOnClose, for the pattern where we show a
confirmation modal on close, conditional on whether the user has made
edits.

---------

Co-authored-by: Alaister Young <a@alaisteryoung.com>
This commit is contained in:
Charis
2025-11-11 13:34:50 -05:00
committed by GitHub
parent 5b1d388507
commit 8828c4e734
18 changed files with 660 additions and 635 deletions

View File

@@ -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<any>('')
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 (
<Modal
hideFooter
@@ -180,25 +195,10 @@ const PolicyEditorModal = ({
onToggleFeaturePreviewModal={onToggleFeaturePreviewModal}
/>,
]}
onCancel={isClosingPolicyEditor}
onCancel={confirmOnClose}
>
<div>
<ConfirmationModal
visible={isClosingPolicyEditorModal}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPolicyEditorModal(false)}
onConfirm={() => {
onSelectCancel()
setIsClosingPolicyEditorModal(false)
setIsDirty(false)
}}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the editor? Your changes will
be lost.
</p>
</ConfirmationModal>
<CloseConfirmationModal {...closeConfirmationModalProps} />
{view === POLICY_MODAL_VIEWS.SELECTION ? (
<PolicySelection
description="Write rules with PostgreSQL's policies to fit your unique business needs."
@@ -233,4 +233,19 @@ const PolicyEditorModal = ({
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the editor? Your changes will be
lost.
</p>
</ConfirmationModal>
)
export default PolicyEditorModal

View File

@@ -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<string>()
const [showTools, setShowTools] = useState<boolean>(false)
const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState<boolean>(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<typeof FormSchema>) => {
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 ({
<>
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<Sheet open={visible} onOpenChange={() => onClosingPanel()}>
<Sheet open={visible} onOpenChange={confirmOnClose}>
<SheetContent
showClose={false}
size={showTools ? 'lg' : 'default'}
@@ -481,7 +484,7 @@ export const PolicyEditorPanel = memo(function ({
<Button
type="default"
disabled={isExecuting || isUpdating}
onClick={() => onClosingPanel()}
onClick={confirmOnClose}
>
Cancel
</Button>
@@ -569,23 +572,24 @@ export const PolicyEditorPanel = memo(function ({
</form>
</Form_Shadcn_>
<ConfirmationModal
visible={isClosingPolicyEditorPanel}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPolicyEditorPanel(false)}
onConfirm={() => {
onSelectCancel()
setIsClosingPolicyEditorPanel(false)
}}
>
<p className="text-sm text-foreground-light">
Are you sure you want to close the editor? Any unsaved changes on your policy and
conversations with the Assistant will be lost.
</p>
</ConfirmationModal>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</>
)
})
PolicyEditorPanel.displayName = 'PolicyEditorPanel'
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
Are you sure you want to close the editor? Any unsaved changes on your policy and
conversations with the Assistant will be lost.
</p>
</ConfirmationModal>
)

View File

@@ -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<z.infer<typeof FormSchema>> = async (data) => {
if (!project) return console.error('Project is required')
const payload = {
@@ -156,7 +157,7 @@ export const CreateFunction = ({
const { data: protectedSchemas } = useProtectedSchemas()
return (
<Sheet open={visible} onOpenChange={() => isClosingSidePanel()}>
<Sheet open={visible} onOpenChange={confirmOnClose}>
<SheetContent
showClose={false}
size={'default'}
@@ -388,7 +389,7 @@ export const CreateFunction = ({
</form>
</Form_Shadcn_>
<SheetFooter>
<Button disabled={isCreating || isUpdating} type="default" onClick={isClosingSidePanel}>
<Button disabled={isCreating || isUpdating} type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button
@@ -401,26 +402,27 @@ export const CreateFunction = ({
</Button>
</SheetFooter>
</div>
<ConfirmationModal
visible={isClosingPanel}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
onClose()
}}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</ConfirmationModal>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</SheetContent>
</Sheet>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)
interface FormFieldConfigParamsProps {
readonly?: boolean
}

View File

@@ -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<any>(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 (
<>
<SidePanel
@@ -281,14 +283,14 @@ export const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelP
}
className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out"
onConfirm={() => {}}
onCancel={() => onClosePanel()}
onCancel={confirmOnClose}
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-default px-3 py-4">
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
onClick={confirmOnClose}
disabled={isSubmitting}
>
Cancel
@@ -328,22 +330,22 @@ export const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelP
}}
</Form>
</SidePanel>
<ConfirmationModal
visible={isClosingPanel}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
setIsEdited(false)
onClose()
}}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -11,6 +11,7 @@ import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/databas
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
import {
Button,
@@ -32,6 +33,7 @@ import {
SheetHeader,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import ChooseFunctionForm from './ChooseFunctionForm'
import {
@@ -40,7 +42,6 @@ import {
TRIGGER_ORIENTATIONS,
TRIGGER_TYPES,
} from './Triggers.constants'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
const formId = 'create-trigger'
@@ -89,7 +90,6 @@ export const TriggerSheet = ({
}: TriggerSheetProps) => {
const { data: project } = useSelectedProjectQuery()
const [isClosingPanel, setIsClosingPanel] = useState(false)
const [showFunctionSelector, setShowFunctionSelector] = useState(false)
const { mutate: createDatabaseTrigger, isLoading: isCreating } = useDatabaseTriggerCreateMutation(
@@ -135,9 +135,10 @@ export const TriggerSheet = ({
})
const { function_name, function_schema } = form.watch()
function isClosingSidePanel() {
form.formState.isDirty ? setIsClosingPanel(true) : onClose()
}
const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
onClose,
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!project) return console.error('Project is required')
@@ -188,7 +189,7 @@ export const TriggerSheet = ({
return (
<>
<Sheet open={open} onOpenChange={isClosingSidePanel}>
<Sheet open={open} onOpenChange={confirmOnClose}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>
@@ -471,7 +472,7 @@ export const TriggerSheet = ({
type="default"
htmlType="reset"
disabled={isCreating || isUpdating}
onClick={onClose}
onClick={confirmOnClose}
>
Cancel
</Button>
@@ -480,21 +481,7 @@ export const TriggerSheet = ({
</Button>
</SheetFooter>
<ConfirmationModal
visible={isClosingPanel}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
onClose()
}}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</ConfirmationModal>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</SheetContent>
</Sheet>
@@ -509,3 +496,18 @@ export const TriggerSheet = ({
</>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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
</Button>
</SheetFooter>
</SheetContent>
{closeConfirmationModal}
<CloseConfirmationModal {...closeConfirmationModalProps} />
</Sheet>
)
}
@@ -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: (
<CloseConfirmationModal
visible={visible}
onClose={onConfirm}
onCancel={() => setVisible(false)}
/>
),
}
}
type CloseConfirmationModalProps = {
visible: boolean
onClose: () => void
onCancel: () => void
}
const CloseConfirmationModal = ({
visible,
onClose,
onCancel,
}: CloseConfirmationModalProps): ReactNode => {
}: ConfirmOnCloseModalProps): ReactNode => {
return (
<ConfirmationModal
visible={visible}

View File

@@ -33,7 +33,6 @@ import {
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { CRONJOB_DEFINITIONS } from '../CronJobs.constants'
import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from '../CronJobs.utils'
@@ -53,9 +52,9 @@ import { CronJobScheduleSection } from './CronJobScheduleSection'
interface CreateCronJobSheetProps {
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
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 = ({
</Button>
</SheetFooter>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
{pgNetExtension && (
<EnableExtensionModal
visible={showEnableExtensionModal}

View File

@@ -9,6 +9,7 @@ import { NavigationItem, PageLayout } from 'components/layouts/PageLayout/PageLa
import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
import {
Button,
cn,
@@ -19,6 +20,7 @@ import {
TooltipContent,
TooltipTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { CreateCronJobSheet } from './CreateCronJobSheet/CreateCronJobSheet'
import { isSecondsFormat, parseCronJobCommand } from './CronJobs.utils'
@@ -31,7 +33,6 @@ export const CronJobPage = () => {
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}
/>
)}
</SheetContent>
</Sheet>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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<CronJob, 'jobname' | 'schedule' | 'active' | 'command'> | 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 (
<>
<div className="h-full w-full space-y-4">
@@ -267,25 +281,33 @@ export const CronjobsTab = () => {
cronJob={cronJobForDeletion!}
/>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
>
<Sheet open={!!createCronJobSheetShown} onOpenChange={confirmOnClose}>
<SheetContent size="default" tabIndex={undefined}>
<CreateCronJobSheet
selectedCronJob={cronJobForEditing ?? EMPTY_CRON_JOB}
supportsSeconds={supportsSeconds}
onClose={() => {
setIsClosingCreateCronJobSheet(false)
setCronJobForEditing(undefined)
setCreateCronJobSheetShown(false)
cleanPointerEventsNoneOnBody(500)
}}
isClosing={isClosingCreateCronJobSheet}
setIsClosing={setIsClosingCreateCronJobSheet}
onDirty={setIsDirty}
onClose={onClose}
onCloseWithConfirmation={confirmOnClose}
/>
</SheetContent>
</Sheet>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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<CreateQueueForm> = async ({ name, enableRls, values }) => {
createQueue(
@@ -143,250 +144,208 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue
const queueType = form.watch('values.type')
return (
<>
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>Create a new queue</SheetTitle>
</SheetHeader>
<Sheet open={visible} onOpenChange={confirmOnClose}>
<SheetContent size="default" className="w-[35%]" tabIndex={undefined}>
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>Create a new queue</SheetTitle>
</SheetHeader>
<div className="overflow-auto flex-grow">
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
className="flex-grow overflow-auto"
onSubmit={form.handleSubmit(onSubmit)}
>
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="vertical" className="gap-1 relative">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
<span className="text-foreground-lighter text-xs absolute top-0 right-0">
Must be all lowercase letters
</span>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="values.type"
render={({ field }) => (
<FormItemLayout label="Type" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<RadioGroupStacked
id="queue_type"
name="queue_type"
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
{QUEUE_TYPES.map((definition) => (
<RadioGroupStackedItem
key={definition.value}
id={definition.value}
value={definition.value}
label=""
disabled={
!pgPartmanExtensionInstalled && definition.value === 'partitioned'
}
showIndicator={false}
>
<div className="flex items-start gap-x-5">
<div className="text-foreground">{definition.icon}</div>
<div className="flex flex-col gap-y-1">
<div className="flex items-center gap-x-2">
<p className="text-foreground text-left">{definition.label}</p>
{definition.value === 'partitioned' && (
<Badge variant="warning">Coming soon</Badge>
)}
</div>
<p className="text-foreground-lighter text-left">
{definition.description}
</p>
</div>
</div>
{/* {!pgPartmanExtensionInstalled &&
definition.value === 'partitioned' ? (
<div className="w-full flex gap-x-2 pl-11 py-2 items-center">
<WarningIcon />
<span className="text-xs">
<code>pg_partman</code> needs to be installed to use this type
</span>
</div>
) : null} */}
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{/* {!pgPartmanExtensionInstalled && (
<Admonition
type="note"
// @ts-ignore
title={
<span>
Enable <code className="text-xs w-min">pg_partman</code> for partitioned
queues
</span>
}
description={
<div className="flex flex-col gap-y-2">
<span>
This will allow you to create partitioned queues which can handle a large
amount of messages
<div className="overflow-auto flex-grow">
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
className="flex-grow overflow-auto"
onSubmit={form.handleSubmit(onSubmit)}
>
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="vertical" className="gap-1 relative">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
<span className="text-foreground-lighter text-xs absolute top-0 right-0">
Must be all lowercase letters
</span>
<ButtonTooltip
type="default"
className="w-min"
disabled={!canToggleExtensions}
onClick={() => setShowEnableExtensionModal(true)}
tooltip={{
content: {
side: 'bottom',
text: 'You need additional permissions to enable database extensions',
},
}}
>
Install pg_partman extension
</ButtonTooltip>
</div>
}
</FormItemLayout>
)}
/>
)} */}
</SheetSection>
<Separator />
{queueType === 'partitioned' && (
<>
<SheetSection className="flex flex-col gap-3">
<FormField_Shadcn_
control={form.control}
name="values.partitionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Partition interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="values.retentionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Retention interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
</>
)}
<SheetSection className="flex flex-col gap-y-2">
<FormField_Shadcn_
control={form.control}
name="enableRls"
render={({ field }) => (
<FormItemLayout
layout="flex"
label={
<div className="flex items-center gap-x-2">
<p>Enable Row Level Security (RLS)</p>
<Badge color="scale">Recommended</Badge>
</div>
}
description="Restrict access to your queue by enabling RLS and writing Postgres policies to control access for each role."
>
<FormControl_Shadcn_>
<Checkbox_Shadcn_
checked={field.value}
onCheckedChange={field.onChange}
disabled={field.disabled || isExposed}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{!isExposed ? (
<Admonition
type="default"
title="Row Level Security for queues is only relevant if exposure through PostgREST has been enabled"
>
<Markdown
className="[&>p]:!leading-normal"
content={`You may opt to manage your queues via any Supabase client libraries or PostgREST
endpoints by enabling this in the [queues settings](/project/${project?.ref}/integrations/queues/settings).`}
/>
</Admonition>
) : (
<Admonition
type="default"
title="RLS must be enabled as queues are exposed via PostgREST"
description="This is to prevent anonymous access to any of your queues"
</SheetSection>
<Separator />
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="values.type"
render={({ field }) => (
<FormItemLayout label="Type" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<RadioGroupStacked
id="queue_type"
name="queue_type"
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
{QUEUE_TYPES.map((definition) => (
<RadioGroupStackedItem
key={definition.value}
id={definition.value}
value={definition.value}
label=""
disabled={
!pgPartmanExtensionInstalled && definition.value === 'partitioned'
}
showIndicator={false}
>
<div className="flex items-start gap-x-5">
<div className="text-foreground">{definition.icon}</div>
<div className="flex flex-col gap-y-1">
<div className="flex items-center gap-x-2">
<p className="text-foreground text-left">
{definition.label}
</p>
{definition.value === 'partitioned' && (
<Badge variant="warning">Coming soon</Badge>
)}
</div>
<p className="text-foreground-lighter text-left">
{definition.description}
</p>
</div>
</div>
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
{queueType === 'partitioned' && (
<>
<SheetSection className="flex flex-col gap-3">
<FormField_Shadcn_
control={form.control}
name="values.partitionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Partition interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="values.retentionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Retention interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
</>
)}
</SheetSection>
</form>
</Form_Shadcn_>
<SheetSection className="flex flex-col gap-y-2">
<FormField_Shadcn_
control={form.control}
name="enableRls"
render={({ field }) => (
<FormItemLayout
layout="flex"
label={
<div className="flex items-center gap-x-2">
<p>Enable Row Level Security (RLS)</p>
<Badge color="scale">Recommended</Badge>
</div>
}
description="Restrict access to your queue by enabling RLS and writing Postgres policies to control access for each role."
>
<FormControl_Shadcn_>
<Checkbox_Shadcn_
checked={field.value}
onCheckedChange={field.onChange}
disabled={field.disabled || isExposed}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{!isExposed ? (
<Admonition
type="default"
title="Row Level Security for queues is only relevant if exposure through PostgREST has been enabled"
>
<Markdown
className="[&>p]:!leading-normal"
content={`You may opt to manage your queues via any Supabase client libraries or PostgREST
endpoints by enabling this in the [queues settings](/project/${project?.ref}/integrations/queues/settings).`}
/>
</Admonition>
) : (
<Admonition
type="default"
title="RLS must be enabled as queues are exposed via PostgREST"
description="This is to prevent anonymous access to any of your queues"
/>
)}
</SheetSection>
</form>
</Form_Shadcn_>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={confirmOnClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isLoading}
loading={isLoading}
>
Create queue
</Button>
</SheetFooter>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isLoading}
loading={isLoading}
>
Create queue
</Button>
</SheetFooter>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
{/* {pgPartmanExtension && (
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgPartmanExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
)} */}
</>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</SheetContent>
</Sheet>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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 = () => {
</div>
</div>
<Sheet open={createQueueSheetShown} onOpenChange={() => setIsClosingCreateQueueSheet(true)}>
<SheetContent size="default" className="w-[35%]" tabIndex={undefined}>
<CreateQueueSheet
onClose={() => {
setIsClosingCreateQueueSheet(false)
setCreateQueueSheetShown(false)
}}
isClosing={isClosingCreateQueueSheet}
setIsClosing={setIsClosingCreateQueueSheet}
/>
</SheetContent>
</Sheet>
<CreateQueueSheet
visible={createQueueSheetShown}
onClose={() => {
setCreateQueueSheetShown(false)
}}
/>
</>
)
}

View File

@@ -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 = ({
}}
</Form>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
</>
)
}

View File

@@ -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<any[]>([])
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 = ({
}}
</Form>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
<WrapperTableEditor
visible={isEditingTable}

View File

@@ -1,7 +1,7 @@
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { Edit, Trash } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
@@ -11,6 +11,7 @@ import { FDW } from 'data/fdw/fdws-query'
import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query'
import { useVaultSecretsQuery } from 'data/vault/vault-secrets-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import InputField from './InputField'
@@ -66,6 +67,7 @@ export const EditWrapperSheet = ({
undefined
)
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
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 (
<>
<div className="flex flex-col h-full" tabIndex={-1}>
@@ -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 = ({
</Form>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
<CloseConfirmationModal {...closeConfirmationModalProps} />
<WrapperTableEditor
visible={isEditingTable}
@@ -369,3 +365,18 @@ export const EditWrapperSheet = ({
</>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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 = () => {
</div>
<Separator />
<Sheet open={!!createWrapperShown} onOpenChange={() => setisClosingCreateWrapper(true)}>
<Sheet open={!!createWrapperShown} onOpenChange={confirmOnClose}>
<SheetContent size="lg" tabIndex={undefined}>
<CreateWrapperSheetComponent
wrapperMeta={wrapperMeta}
onDirty={(dirty) => setIsDirty(dirty)}
onClose={() => {
setCreateWrapperShown(false)
setisClosingCreateWrapper(false)
}}
isClosing={isClosingCreateWrapper}
setIsClosing={setisClosingCreateWrapper}
onCloseWithConfirmation={confirmOnClose}
/>
</SheetContent>
</Sheet>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</IntegrationOverviewTab>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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<FDW | null>(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<HTMLDivElement>) => (
<div className="w-full mx-10 py-10 ">
{props.children}
<Sheet open={!!createWrapperShown} onOpenChange={() => setisClosingCreateWrapper(true)}>
<Sheet open={!!createWrapperShown} onOpenChange={confirmOnClose}>
<SheetContent size="lg" tabIndex={undefined}>
{wrapperMeta && (
<CreateWrapperSheet
wrapperMeta={wrapperMeta}
onClose={() => {
setCreateWrapperShown(false)
setisClosingCreateWrapper(false)
}}
isClosing={isClosingCreateWrapper}
setIsClosing={setisClosingCreateWrapper}
onDirty={setIsDirty}
onClose={() => setCreateWrapperShown(false)}
onCloseWithConfirmation={confirmOnClose}
/>
)}
</SheetContent>
</Sheet>
</div>
),
[createWrapperShown, wrapperMeta, isClosingCreateWrapper]
[createWrapperShown, wrapperMeta, confirmOnClose]
)
if (!wrapperMeta) {
@@ -103,6 +110,22 @@ export const WrappersTab = () => {
onClose={() => setSelectedWrapperForDelete(null)}
/>
)}
<CloseConfirmationModal {...closeConfirmationModalProps} />
</Container>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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<boolean>(false)
const [isClosingPanel, setIsClosingPanel] = useState<boolean>(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}
/>
<ConfirmationModal
visible={isClosingPanel}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
setIsEdited(false)
snap.closeSidePanel()
}}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</>
)
}
const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)

View File

@@ -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]
)
}