mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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,
|
||||
|
||||
+11
-27
@@ -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>
|
||||
)
|
||||
}
|
||||
+52
-100
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user