chore(studio): standardise discard changes behaviour (#43201)

## What kind of change does this PR introduce?

UX consistency improvement. Updates DEPR-355.

## What is the current behavior?

Discard-confirm close behavioir is implemented inconsistently across
Studio forms:

- some sheets/dialogs used `useConfirmOnClose`
- some duplicated local `CloseConfirmationModal` components
- some (e.g. `CreateHookSheet`) closed unconditionally and could lose
unsaved changes

## What is the new behavior?

Extracts and validates a reusable discard-close pattern for
dialogs/sheets
- enhances `useConfirmOnClose` with `handleOpenChange(open)` for
`Dialog`/`Sheet` `onOpenChange`
- adds shared `DiscardChangesConfirmationDialog` (`AlertDialog`-based,
override-able copy)
- migrates:
	- `InviteMemberButton`
	- `CreateHookSheet`
	- `EditSecretSheet`

This standardizes close-guard behavior for
backdrop/escape/close-button/cancel-button flows without trying to block
route changes or arbitrary unmounts.

## Additional context

`CreateHookSheet` now also marks the generated secret action as dirty
(`setValue(..., { shouldDirty: true })`) so the discard guard behaves
correctly.

- Added tests for `useConfirmOnClose` covering:
	- clean vs dirty close
	- handleOpenChange(true|false)
	- confirm/cancel behavior
	- latest callback ref behavior

A follow-up PR is needed to migrate remaining duplicated
`CloseConfirmationModal` usages and older `useConfirmOnClose` call sites
to the shared `DiscardChangesConfirmationDialog` + `handleOpenChange`
pattern.

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-03-05 11:32:39 +11:00
1 parent cd0e2c9fa9
commit aef1d70351
11 files changed
+374 -146

No files matched your search

@@ -99,6 +99,7 @@ This enforced decision helps prevent accidental dismissal of critical warnings o
- **Keep content concise:** AlertDialogDescription renders as a single paragraph and must not contain block-level elements such as lists, multiple paragraphs, or complex layouts.
- **Use for critical decisions only:** Reserve Alert Dialog for destructive or irreversible actions, or for warnings that require explicit acknowledgement.
- **Use for dirty-form discard confirmation:** A short discard-confirmation step after a dirty form dismissal attempt (backdrop, Escape, or `Cancel`) is a valid Alert Dialog pattern. In Studio, prefer `DiscardChangesConfirmationDialog` for this flow.
- **Always provide a cancel action:** Include AlertDialogCancel so users can safely back out, in addition to supporting the Escape key.
- **Avoid rich content:** If the dialog requires detailed explanations, callouts, or form inputs, use [Confirmation Modal](../fragments/confirmation-modal) or [Dialog](../components/dialog) instead.
@@ -58,6 +58,7 @@ import {
- **Use for non-critical interactions:** Dialog is appropriate when dismissal has no serious consequences.
- **Design for cancellation**: Assume users may close the dialog without completing the action.
- **Guard dirty forms on dismissal:** For form dialogs, keep `Dialog` dismissible but intercept close attempts when the form is dirty and show a discard-confirmation dialog (for example, Studio's `DiscardChangesConfirmationDialog` pattern via `useConfirmOnClose`).
- **Keep focus contained**: Dialog content should remain scoped to a single task or flow.
- **Avoid destructive confirmations**: If the dialog’s primary purpose is to confirm a risky action, use a confirmation-focused pattern instead.
- **Compose freely**: Dialog is intentionally unopinionated. Build custom layouts, forms, or step-based flows as needed.
@@ -10,6 +10,8 @@ Use Confirmation Modal when the user needs extra context to make a decision, suc
If the confirmation can be expressed as a single short paragraph, use [Alert Dialog](../components/alert-dialog). If the action is highly destructive and requires explicit typed intent, use [Text Confirm Dialog](../fragments/text-confirm-dialog). See [Modality](../ui-patterns/modality) for broader guidance on choosing the appropriate pattern.
For dirty-form dismissal in dialogs/sheets, prefer the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) rather than creating new local `CloseConfirmationModal` wrappers. The ad-hoc `CloseConfirmationModal` wrapper pattern is deprecated for new implementations.
<ComponentPreview name="confirmation-modal-demo" peekCode wide />
## Usage
@@ -54,6 +56,7 @@ export default function ConfirmationModalDemo() {
## Guidelines
- **Use for moderate complexity:** Suitable when the confirmation requires more than a single sentence but does not need typed intent.
- **Do not use for standard dirty-form dismissal:** Prefer `DiscardChangesConfirmationDialog` for unsaved-changes prompts so copy, behavior, and wiring stay consistent across dialogs/sheets.
- **Avoid critical destruction:** Do not use for irreversible or high-risk actions that could benefit from stronger safeguards.
- **Keep content focused:** Include only the context needed to make the decision. If the dialog becomes a full flow, use a custom [Dialog](../components/dialog) instead.
- **Provide clear actions:** Ensure confirm and cancel labels clearly describe the outcome of each choice.
@@ -18,6 +18,18 @@ We have two main ways of handling modality:
As a general rule: use dialogs for short, focused tasks and use sheets for longer forms or more detailed views.
### Dirty form dismissal pattern
When a dialog or sheet contains a form, users should generally be allowed to attempt dismissal via all normal affordances (backdrop click, Escape key, close icon, and footer `Cancel` button).
If the form is clean, close immediately. If the form has unsaved changes, show a discard-confirmation dialog instead of closing immediately.
This pattern is implemented in Studio with `useConfirmOnClose` plus `DiscardChangesConfirmationDialog`.
- **Prompt on footer `Cancel` too:** If a button is labeled `Cancel`, users expect it to stop the current action, not silently discard edits. Prompting keeps behavior consistent with backdrop/Escape dismissal and prevents accidental loss.
- **Use explicit labels for no-prompt discard:** If you intentionally want a one-click destructive exit, label the action `Discard` (or `Discard changes`) rather than `Cancel`.
- **Guard close attempts, not unmounts:** This pattern should intercept controlled modal/sheet close attempts (`onOpenChange`, close buttons, footer actions). It should not attempt to block route changes or arbitrary component unmounts.
## Dialogs
Dialogs are centered overlays used for short, focused tasks. All dialogs should follow these best practices:
@@ -41,6 +53,22 @@ There are quite a few dialog components, each suited to a different task or cont
<ComponentPreview name="alert-dialog-demo" />
#### Discard changes confirmation dialog (pattern)
For dirty form dismissal, use a short discard-confirmation dialog after a close attempt instead of disabling dismissal entirely.
- The primary form remains in a `Dialog` or `Sheet` (dismissible).
- Closing is intercepted only when the form is dirty.
- The follow-up confirmation is an `AlertDialog` pattern (`DiscardChangesConfirmationDialog` in Studio).
Typical flow:
1. User attempts to close the dialog/sheet (backdrop, Escape, close icon, or `Cancel`)
2. If the form is clean, close immediately
3. If the form is dirty, show discard confirmation
4. `Keep editing` returns to the form
5. `Discard changes` closes and resets the form
#### Text Confirm Dialog
[Text Confirm Dialog](../fragments/text-confirm-dialog) adds a deliberate speed bump for highly destructive actions by requiring the user to type an exact confirmation string before proceeding. The confirm action remains disabled until the input matches.
@@ -1,6 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
import { convertArgumentTypes } from 'components/interfaces/Database/Functions/Functions.utils'
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { DocsButton } from 'components/ui/DocsButton'
import FunctionSelector from 'components/ui/FunctionSelector'
@@ -9,6 +10,7 @@ import { AuthConfigResponse } from 'data/auth/auth-config-query'
import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation'
import { executeSql } from 'data/sql/execute-sql-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
import { DOCS_URL } from 'lib/constants'
import randomBytes from 'randombytes'
import { useEffect, useMemo } from 'react'
@@ -156,7 +158,16 @@ export const CreateHookSheet = ({
},
})
const isDirty = form.formState.isDirty
const values = form.watch()
const {
confirmOnClose,
handleOpenChange,
modalProps: discardChangesModalProps,
} = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose,
})
const statements = useMemo(() => {
let permissionChanges: string[] = []
@@ -270,7 +281,7 @@ export const CreateHookSheet = ({
}, [authConfig, title, visible, definition])
return (
<Sheet open={visible} onOpenChange={() => onClose()}>
<Sheet open={visible} onOpenChange={handleOpenChange}>
<SheetContent
aria-describedby={undefined}
size="lg"
@@ -411,17 +422,20 @@ export const CreateHookSheet = ({
)}
/>
</div>
<div className="h-72 w-full gap-3 flex flex-col">
<p className="text-sm text-foreground-light px-5">
The following statements will be executed on the selected function:
</p>
<CodeEditor
id="postgres-hook-editor"
isReadOnly={true}
language="pgsql"
value={statements.join('\n\n')}
/>
</div>
{statements.length > 0 && (
<div className="h-72 w-full gap-3 flex flex-col">
<p className="text-sm text-foreground-light px-5">
The following statements will be executed on the selected function:
</p>
<CodeEditor
isReadOnly
id="postgres-hook-editor"
language="pgsql"
value={statements.join('\n\n')}
/>
</div>
)}
</>
) : (
<div className="flex flex-col gap-4 px-5">
@@ -470,7 +484,9 @@ export const CreateHookSheet = ({
className="rounded-l-none text-xs"
onClick={() => {
const authHookSecret = generateAuthHookSecret()
form.setValue('httpsValues.secret', authHookSecret)
form.setValue('httpsValues.secret', authHookSecret, {
shouldDirty: true,
})
}}
>
Generate secret
@@ -494,7 +510,7 @@ export const CreateHookSheet = ({
</div>
)}
<Button disabled={isUpdatingAuthHooks} type="default" onClick={() => onClose()}>
<Button disabled={isUpdatingAuthHooks} type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button
@@ -507,6 +523,7 @@ export const CreateHookSheet = ({
</Button>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog {...discardChangesModalProps} />
</Sheet>
)
}
@@ -165,6 +165,7 @@ export const HooksListing = () => {
variant="destructive"
loading={isDeletingAuthHook}
title={`Confirm to delete ${selectedHookForDeletion?.title}`}
className={cn('md:px-0', selectedHookForDeletion?.method.type === 'postgres' && 'pb-0')}
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={() => setSelectedHookForDeletion(null)}
@@ -181,20 +182,20 @@ export const HooksListing = () => {
}}
>
<div>
<p className="text-sm text-foreground-light">
<p className="md:px-5 text-sm text-foreground-light">
Are you sure you want to delete the {selectedHookForDeletion?.title}?
</p>
{selectedHookForDeletion?.method.type === 'postgres' && (
<>
<p className="text-sm text-foreground-light">
<p className="md:px-5 text-sm text-foreground-light">
The following statements will be executed on the{' '}
{selectedHookForDeletion?.method.schema}.
{selectedHookForDeletion?.method.functionName} function:
</p>
<div className={cn('mt-4', 'h-72')}>
<div className="mt-4 h-72">
<CodeEditor
isReadOnly
id="deletion-hook-editor"
isReadOnly={true}
language="pgsql"
value={getRevokePermissionStatements(
selectedHookForDeletion?.method.schema,
@@ -5,9 +5,10 @@ import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation'
import { ProjectSecret } from 'data/secrets/secrets-query'
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
import { Eye, EyeOff, X } from 'lucide-react'
import { useLatest } from 'react-use'
import {
@@ -27,7 +28,6 @@ import {
SheetSection,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const FORM_ID = 'edit-secret-sidepanel'
@@ -59,6 +59,7 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro
}
}, [form, secretName, visible])
const isValid = form.formState.isValid
const isDirty = form.formState.isDirty
const { ref: projectRef } = useParams()
const { mutate: updateSecret, isPending: isUpdating } = useSecretsCreateMutation({
@@ -74,13 +75,17 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro
})
}
const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
const {
confirmOnClose,
handleOpenChange,
modalProps: closeConfirmationModalProps,
} = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose,
})
return (
<Sheet open={visible} onOpenChange={confirmOnClose}>
<Sheet open={visible} onOpenChange={handleOpenChange}>
<SheetContent
showClose={false}
size={'default'}
@@ -98,7 +103,7 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro
</Button>
</SheetFooter>
</SheetContent>
<CloseConfirmationModal {...closeConfirmationModalProps} />
<DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
</Sheet>
)
}
@@ -211,24 +216,3 @@ const SecretField = ({ form }: SecretFieldProps): ReactNode => {
/>
)
}
const CloseConfirmationModal = ({
visible,
onClose,
onCancel,
}: ConfirmOnCloseModalProps): ReactNode => {
return (
<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>
)
}
@@ -1,12 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
@@ -14,45 +9,44 @@ import { useOrganizationCreateInvitationMutation } from 'data/organization-membe
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query'
import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions'
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
import { DOCS_URL } from 'lib/constants'
import { useProfile } from 'lib/profile'
import { UserPlus } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
ExpandingTextArea,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
DialogFooter,
Form_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
Select_Shadcn_,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
import { UserPlus } from 'lucide-react'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
import { DocsButton } from '@/components/ui/DocsButton'
function parseEmails(value: string): string[] {
return value
@@ -72,13 +66,19 @@ export const InviteMemberButton = () => {
])
const [isOpen, setIsOpen] = useState(false)
const [isDiscardConfirmOpen, setIsDiscardConfirmOpen] = useState(false)
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false)
const { data: members } = useOrganizationMembersQuery({ slug })
const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug })
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
const defaultValues = {
email: '',
role: orgScopedRoles.find((role) => role.name === 'Developer')?.id.toString() ?? '',
applyToOrg: true,
projectRef: '',
}
const { hasAccess: hasAccessToSso } = useCheckEntitlements('auth.platform.sso')
const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string)
@@ -140,7 +140,7 @@ export const InviteMemberButton = () => {
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(FormSchema),
defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' },
defaultValues,
})
const { applyToOrg, projectRef, email } = form.watch()
@@ -150,8 +150,6 @@ export const InviteMemberButton = () => {
const onInviteMember = async (values: z.infer<typeof FormSchema>) => {
if (!slug) return console.error('Slug is required')
if (profile?.id === undefined) return console.error('Profile ID required')
const developerRole = orgScopedRoles.find((role) => role.name === 'Developer')
const emails = parseEmails(values.email).map((e) => e.toLowerCase())
const alreadyInvited: string[] = []
@@ -211,13 +209,7 @@ export const InviteMemberButton = () => {
? 'Successfully sent invitation to new member'
: `Successfully sent invitations to ${successCount} new members`
)
setIsOpen(false)
form.reset({
email: '',
role: developerRole?.id.toString() ?? '',
applyToOrg: true,
projectRef: '',
})
closeInviteDialog()
}
if (failedEmails.length > 0) {
toast.error(
@@ -230,12 +222,12 @@ export const InviteMemberButton = () => {
useEffect(() => {
if (isSuccess && isOpen) {
const developerRole = orgScopedRoles.find((role) => role.name === 'Developer')
if (developerRole !== undefined) {
form.reset({
...form.getValues(),
role: developerRole.id.toString(),
})
const developerRoleId = orgScopedRoles
.find((role) => role.name === 'Developer')
?.id.toString()
if (developerRoleId !== undefined && form.getValues('role') === '') {
form.setValue('role', developerRoleId, { shouldDirty: false })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -243,39 +235,21 @@ export const InviteMemberButton = () => {
const hasUnsavedChanges = form.formState.isDirty
const handleOpenChange = (open: boolean) => {
if (!open && hasUnsavedChanges) {
setIsDiscardConfirmOpen(true)
} else {
setIsOpen(open)
}
}
const handleCancel = () => {
if (hasUnsavedChanges) {
setIsDiscardConfirmOpen(true)
} else {
form.reset({
email: '',
role: '',
applyToOrg: true,
projectRef: '',
})
setIsOpen(false)
}
}
const handleDiscardConfirm = () => {
form.reset({
email: '',
role: '',
applyToOrg: true,
projectRef: '',
})
setIsDiscardConfirmOpen(false)
const closeInviteDialog = () => {
setProjectDropdownOpen(false)
setIsOpen(false)
form.reset(defaultValues)
}
const {
confirmOnClose,
handleOpenChange,
modalProps: discardChangesModalProps,
} = useConfirmOnClose({
checkIsDirty: () => hasUnsavedChanges,
onClose: closeInviteDialog,
})
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
@@ -313,18 +287,14 @@ export const InviteMemberButton = () => {
description="Enforce login via your company identity provider for added security and access control. Available on Team plan and above."
actions={
<>
<Button asChild type="default">
<Link href={`${DOCS_URL}/guides/platform/sso`} target="_blank" rel="noreferrer">
Learn more
</Link>
</Button>
{!hasAccessToSso ? (
<DocsButton href={`${DOCS_URL}/guides/platform/sso`} />
{!hasAccessToSso && (
<UpgradePlanButton
plan="Team"
source="inviteMemberSSO"
featureProposition="enable Single Sign-on (SSO)"
/>
) : null}
)}
</>
}
/>
@@ -341,10 +311,7 @@ export const InviteMemberButton = () => {
render={({ field }) => (
<FormItemLayout label="Role">
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={(value) => form.setValue('role', value)}
>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_ className="text-sm capitalize">
{orgScopedRoles.find((role) => role.id === Number(field.value))?.name ??
'Unknown'}
@@ -382,10 +349,7 @@ export const InviteMemberButton = () => {
render={({ field }) => (
<FormItemLayout layout="flex" label="Grant this role on all projects">
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => form.setValue('applyToOrg', value)}
/>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -441,7 +405,7 @@ export const InviteMemberButton = () => {
/>
</DialogSection>
<DialogFooter className="!justify-between">
<Button type="default" onClick={handleCancel}>
<Button type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={isInviting}>
@@ -451,22 +415,10 @@ export const InviteMemberButton = () => {
</form>
</Form_Shadcn_>
</DialogContent>
<AlertDialog open={isDiscardConfirmOpen} onOpenChange={setIsDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to discard your changes? Your invitation will not be sent.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction variant="danger" onClick={handleDiscardConfirm}>
Discard changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DiscardChangesConfirmationDialog
{...discardChangesModalProps}
description="Are you sure you want to discard your changes? Your invitation will not be sent."
/>
</Dialog>
)
}
@@ -0,0 +1,78 @@
'use client'
import { useCallback, useEffect, useRef, type ReactNode } from 'react'
import { type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from 'ui'
interface DiscardChangesConfirmationDialogProps extends ConfirmOnCloseModalProps {
title?: ReactNode
description?: ReactNode
confirmLabel?: ReactNode
cancelLabel?: ReactNode
}
export const DiscardChangesConfirmationDialog = ({
visible,
onClose,
onCancel,
title = 'Discard changes?',
description = 'Are you sure you want to discard your changes? Any unsaved changes will be lost.',
confirmLabel = 'Discard changes',
cancelLabel = 'Keep editing',
}: DiscardChangesConfirmationDialogProps) => {
const isConfirmingRef = useRef(false)
useEffect(() => {
if (visible) {
isConfirmingRef.current = false
}
}, [visible])
const handleConfirm = useCallback(() => {
isConfirmingRef.current = true
onClose()
}, [onClose])
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) return
if (isConfirmingRef.current) {
isConfirmingRef.current = false
return
}
onCancel()
},
[onCancel]
)
return (
<AlertDialog open={visible} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description !== undefined && description !== null && (
<AlertDialogDescription>{description}</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="danger" onClick={handleConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,153 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useConfirmOnClose } from './useConfirmOnClose'
describe('useConfirmOnClose', () => {
it('closes immediately when the form is clean', () => {
const onClose = vi.fn()
const { result } = renderHook(() =>
useConfirmOnClose({
checkIsDirty: () => false,
onClose,
})
)
act(() => {
result.current.confirmOnClose()
})
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.modalProps.visible).toBe(false)
})
it('opens the confirmation modal when the form is dirty', () => {
const onClose = vi.fn()
const { result } = renderHook(() =>
useConfirmOnClose({
checkIsDirty: () => true,
onClose,
})
)
act(() => {
result.current.confirmOnClose()
})
expect(onClose).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(true)
})
it('ignores open events and handles close events via handleOpenChange', () => {
const onClose = vi.fn()
const { result } = renderHook(() =>
useConfirmOnClose({
checkIsDirty: () => true,
onClose,
})
)
act(() => {
result.current.handleOpenChange(true)
})
expect(onClose).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(false)
act(() => {
result.current.handleOpenChange(false)
})
expect(onClose).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(true)
})
it('confirms and closes after the discard modal is accepted', () => {
const onClose = vi.fn()
const { result } = renderHook(() =>
useConfirmOnClose({
checkIsDirty: () => true,
onClose,
})
)
act(() => {
result.current.confirmOnClose()
})
expect(result.current.modalProps.visible).toBe(true)
act(() => {
result.current.modalProps.onClose()
})
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.modalProps.visible).toBe(false)
})
it('cancels and keeps the form open after the discard modal is dismissed', () => {
const onClose = vi.fn()
const { result } = renderHook(() =>
useConfirmOnClose({
checkIsDirty: () => true,
onClose,
})
)
act(() => {
result.current.confirmOnClose()
})
expect(result.current.modalProps.visible).toBe(true)
act(() => {
result.current.modalProps.onCancel()
})
expect(onClose).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(false)
})
it('uses the latest checkIsDirty and onClose callbacks', () => {
const onCloseA = vi.fn()
const onCloseB = vi.fn()
let isDirty = false
const { result, rerender } = renderHook(
({ onClose }: { onClose: () => void }) =>
useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose,
}),
{
initialProps: { onClose: onCloseA },
}
)
act(() => {
result.current.confirmOnClose()
})
expect(onCloseA).toHaveBeenCalledTimes(1)
isDirty = true
rerender({ onClose: onCloseB })
act(() => {
result.current.confirmOnClose()
})
expect(result.current.modalProps.visible).toBe(true)
act(() => {
result.current.modalProps.onClose()
})
expect(onCloseB).toHaveBeenCalledTimes(1)
})
})
+11 -1
View File
@@ -27,6 +27,15 @@ export const useConfirmOnClose = ({ checkIsDirty, onClose }: UseConfirmOnClosePr
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
confirmOnClose()
}
},
[confirmOnClose]
)
const onConfirm = useCallback(() => {
setVisible(false)
onCloseRef.current()
@@ -49,8 +58,9 @@ export const useConfirmOnClose = ({ checkIsDirty, onClose }: UseConfirmOnClosePr
return useMemo(
() => ({
confirmOnClose,
handleOpenChange,
modalProps,
}),
[confirmOnClose, modalProps]
[confirmOnClose, handleOpenChange, modalProps]
)
}