mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix: Prevent users from loosing changes when navigating away (#43577)
## Problem When editing email templates or edge functions, users may navigate away from the page without a warning indicating they may loose their changes. This is because we only handle the `beforeunload` event when we should also handle NextJS routing events. This is actually done for the observability reports. ## Solution Extract the logic from the observability reports into a reusable component and use it where needed ## How to test On staging, for each case: - Authentication email templates - Observability reports - Edge functions creation - Edge functions edition Test: - Modify the template/report/function - Navigate away using either the sidebar link, the browser back button or closing the tab - Cancel navigation in the confirmation dialog - Navigation should be prevented and you should not loose your changes Test: - Modify the template/report/function - Navigate away using either the sidebar link, the browser back button or closing the tab - Confirm navigation in the confirmation dialog - Navigation should not be prevented and you should have lost your changes
This commit is contained in:
1 parent
423ec30bec
commit
87581e02cb
6 files changed
+92
-109
No files matched your search
@@ -29,6 +29,7 @@ import {
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { SpamValidation } from './SpamValidation'
|
||||
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
|
||||
|
||||
interface TemplateEditorProps {
|
||||
template: FormSchema
|
||||
@@ -202,21 +203,6 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
}
|
||||
}, [authConfig, properties, messageSlug, form])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [hasUnsavedChanges])
|
||||
|
||||
useEffect(() => {
|
||||
if (projectRef && id && !!authConfig) {
|
||||
const [subjectKey] = Object.keys(properties)
|
||||
@@ -376,6 +362,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
<PreventNavigationOnUnsavedChanges hasChanges={hasChanges} />
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig'
|
||||
import { GridResize } from './GridResize'
|
||||
import { MetricOptions } from './MetricOptions'
|
||||
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
|
||||
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
|
||||
|
||||
const DEFAULT_CHART_COLUMN_COUNT = 1
|
||||
const DEFAULT_CHART_ROW_COUNT = 1
|
||||
@@ -57,9 +58,6 @@ const Reports = () => {
|
||||
const [hasEdits, setHasEdits] = useState<boolean>(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false)
|
||||
|
||||
const [navigateUrl, setNavigateUrl] = useState<string>()
|
||||
const [confirmNavigate, setConfirmNavigate] = useState(false)
|
||||
|
||||
const {
|
||||
data: userContents,
|
||||
isPending: isLoading,
|
||||
@@ -364,31 +362,6 @@ const Reports = () => {
|
||||
checkEditState()
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasEdits) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowseAway = (url: string) => {
|
||||
if (hasEdits && !confirmNavigate) {
|
||||
setNavigateUrl(url)
|
||||
throw 'Route change declined' // Just to prevent the route change
|
||||
} else {
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.on('routeChangeStart', handleBrowseAway)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.off('routeChangeStart', handleBrowseAway)
|
||||
}
|
||||
}, [hasEdits, confirmNavigate, router])
|
||||
|
||||
if (isLoading || isLoadingPermissions) {
|
||||
return <LogoLoader />
|
||||
}
|
||||
@@ -528,27 +501,7 @@ const Reports = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
visible={!!navigateUrl}
|
||||
variant="warning"
|
||||
title="You have unsaved changes in your report"
|
||||
confirmLabel="Confirm"
|
||||
onConfirm={() => {
|
||||
setConfirmNavigate(true)
|
||||
let urlToNavigate = navigateUrl ?? '/'
|
||||
if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) {
|
||||
urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/'
|
||||
}
|
||||
if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}`
|
||||
setNavigateUrl(undefined)
|
||||
router.push(urlToNavigate)
|
||||
}}
|
||||
onCancel={() => setNavigateUrl(undefined)}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Unsaved changes will be lost, are you sure you want to navigate away?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
<PreventNavigationOnUnsavedChanges hasChanges={hasEdits} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from 'ui'
|
||||
|
||||
interface DiscardChangesConfirmationDialogProps extends ConfirmOnCloseModalProps {
|
||||
export interface DiscardChangesConfirmationDialogProps extends ConfirmOnCloseModalProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
confirmLabel?: ReactNode
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
import {
|
||||
DiscardChangesConfirmationDialog,
|
||||
type DiscardChangesConfirmationDialogProps,
|
||||
} from './DiscardChangesConfirmationDialog'
|
||||
|
||||
export const PreventNavigationOnUnsavedChanges = ({
|
||||
hasChanges,
|
||||
...props
|
||||
}: { hasChanges: boolean } & Omit<
|
||||
DiscardChangesConfirmationDialogProps,
|
||||
'visible' | 'onClose' | 'onCancel'
|
||||
>) => {
|
||||
const router = useRouter()
|
||||
const [navigateUrl, setNavigateUrl] = useState<string>()
|
||||
const [confirmNavigate, setConfirmNavigate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowseAway = (url: string) => {
|
||||
if (hasChanges && !confirmNavigate) {
|
||||
setNavigateUrl(url)
|
||||
throw 'Route change declined' // Just to prevent the route change
|
||||
return
|
||||
}
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.on('routeChangeStart', handleBrowseAway)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
router.events.off('routeChangeStart', handleBrowseAway)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [confirmNavigate, hasChanges])
|
||||
|
||||
const handleCancel = () => {
|
||||
setNavigateUrl(undefined)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setConfirmNavigate(true)
|
||||
let urlToNavigate = navigateUrl ?? '/'
|
||||
if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) {
|
||||
urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/'
|
||||
}
|
||||
if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}`
|
||||
setNavigateUrl(undefined)
|
||||
router.push(urlToNavigate)
|
||||
}
|
||||
|
||||
return (
|
||||
<DiscardChangesConfirmationDialog
|
||||
visible={!!navigateUrl}
|
||||
onCancel={handleCancel}
|
||||
onClose={handleClose}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { isEqual } from 'lodash'
|
||||
import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils'
|
||||
import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types'
|
||||
import { useLatest } from '@/hooks/misc/useLatest'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { DeployEdgeFunctionWarningModal } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal'
|
||||
import { DefaultLayout } from 'components/layouts/DefaultLayout'
|
||||
@@ -21,8 +13,16 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { isEqual } from 'lodash'
|
||||
import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { LogoLoader } from 'ui'
|
||||
|
||||
import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils'
|
||||
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
|
||||
import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types'
|
||||
|
||||
const CodePage = () => {
|
||||
const { ref, functionSlug } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
@@ -146,27 +146,11 @@ const CodePage = () => {
|
||||
setFiles(initialFiles)
|
||||
}, [initialFiles])
|
||||
|
||||
// [Joshen] Probably a candidate for useStaticEffectEvent
|
||||
const filesRef = useLatest(files)
|
||||
const initialFilesRef = useLatest(initialFiles)
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
const normalizeFiles = (list: FileData[]) =>
|
||||
list.map(({ id, name, content }) => ({ id, name, content }))
|
||||
const hasUnsavedChanges = !isEqual(
|
||||
normalizeFiles(initialFilesRef.current),
|
||||
normalizeFiles(filesRef.current)
|
||||
)
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
const normalizeFiles = (list: FileData[]) =>
|
||||
list.map(({ id, name, content }) => ({ id, name, content }))
|
||||
return !isEqual(normalizeFiles(initialFiles), normalizeFiles(files))
|
||||
}, [initialFiles, files])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -252,6 +236,7 @@ const CodePage = () => {
|
||||
onConfirm={handleDeployConfirm}
|
||||
isDeploying={isDeploying}
|
||||
/>
|
||||
<PreventNavigationOnUnsavedChanges hasChanges={hasUnsavedChanges} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from 'ui'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
|
||||
import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types'
|
||||
import { useLatest } from '@/hooks/misc/useLatest'
|
||||
|
||||
@@ -276,21 +277,7 @@ const NewFunctionPage = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [template])
|
||||
|
||||
// [Joshen] Probably a candidate for useStaticEffectEvent
|
||||
const filesRef = useLatest(files)
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
const hasUnsavedChanges = !isEqual(INITIAL_FILES, filesRef.current)
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // deprecated, but older browsers still require this
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
const hasUnsavedChanges = useMemo(() => !isEqual(INITIAL_FILES, files), [files])
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
@@ -428,6 +415,7 @@ const NewFunctionPage = () => {
|
||||
</Button>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
<PreventNavigationOnUnsavedChanges hasChanges={hasUnsavedChanges} />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user