Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions apps/design-system/content/docs/components/dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions apps/design-system/content/docs/ui-patterns/modality.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down
45 changes: 31 additions & 14 deletions apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -507,6 +523,7 @@ export const CreateHookSheet = ({
</Button>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog {...discardChangesModalProps} />
</Sheet>
)
}
9 changes: 5 additions & 4 deletions apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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,
Expand Down
26 changes: 13 additions & 13 deletions apps/studio/components/interfaces/Auth/PerformanceSettingsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { UpgradeToPro } from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { IS_PLATFORM } from 'lib/constants'
import {
Expand Down Expand Up @@ -46,7 +46,8 @@ const FormSchema = z.object({

export const PerformanceSettingsForm = () => {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const { hasAccess: hasAccessToPerformance, isLoading: isLoadingEntitlement } =
useCheckEntitlements('auth.performance_settings')
const { can: canReadConfig } = useAsyncCheckPermissions(
PermissionAction.READ,
'custom_config_gotrue'
Expand All @@ -72,8 +73,7 @@ export const PerformanceSettingsForm = () => {
})
const maxConnectionLimit = maxConnData?.maxConnections ?? 60

const isProPlan = organization?.plan.id !== 'free'
const promptProPlanUpgrade = IS_PLATFORM && !isProPlan
const promptUpgrade = IS_PLATFORM && !isLoadingEntitlement && !hasAccessToPerformance

const { mutate: updateAuthConfig, isPending: isSaving } = useAuthConfigUpdateMutation()

Expand Down Expand Up @@ -101,7 +101,7 @@ export const PerformanceSettingsForm = () => {

const onSubmitRequestDurationForm = (values: any) => {
if (!project?.ref) return console.error('Project ref is required')
if (!isProPlan) return
if (!hasAccessToPerformance) return

setIsUpdatingRequestDurationForm(true)

Expand Down Expand Up @@ -181,7 +181,7 @@ export const PerformanceSettingsForm = () => {
)
}

if (isLoadingAuthConfig) {
if (isLoadingAuthConfig || isLoadingEntitlement) {
return (
<ScaffoldSection isFullWidth>
<GenericSkeletonLoader />
Expand All @@ -192,7 +192,7 @@ export const PerformanceSettingsForm = () => {
return (
<>
<ScaffoldSection isFullWidth>
{promptProPlanUpgrade && (
{promptUpgrade && (
<UpgradeToPro
source="authPerformance"
featureProposition="configure advanced Auth server settings"
Expand Down Expand Up @@ -232,7 +232,7 @@ export const PerformanceSettingsForm = () => {
min={5}
max={30}
{...field}
disabled={!canUpdateConfig || promptProPlanUpgrade}
disabled={!canUpdateConfig || promptUpgrade}
/>
</PrePostTab>
</div>
Expand All @@ -254,13 +254,13 @@ export const PerformanceSettingsForm = () => {
</Button>
)}
<Button
type={promptProPlanUpgrade ? 'default' : 'primary'}
type={promptUpgrade ? 'default' : 'primary'}
htmlType="submit"
disabled={
!canUpdateConfig ||
isUpdatingRequestDurationForm ||
!requestDurationForm.formState.isDirty ||
promptProPlanUpgrade
promptUpgrade
}
loading={isUpdatingRequestDurationForm}
>
Expand Down Expand Up @@ -325,7 +325,7 @@ export const PerformanceSettingsForm = () => {
>
<SelectTrigger_Shadcn_
size="small"
disabled={!canUpdateConfig || promptProPlanUpgrade}
disabled={!canUpdateConfig || promptUpgrade}
>
<SelectValue_Shadcn_>
{field.value === 'percent' ? 'Percentage' : 'Absolute'}
Expand Down Expand Up @@ -376,7 +376,7 @@ export const PerformanceSettingsForm = () => {
? 80
: Math.floor(maxConnectionLimit * 0.8)
}
disabled={!canUpdateConfig || promptProPlanUpgrade}
disabled={!canUpdateConfig || promptUpgrade}
/>
</PrePostTab>
</div>
Expand Down Expand Up @@ -408,7 +408,7 @@ export const PerformanceSettingsForm = () => {
</Button>
)}
<Button
type={promptProPlanUpgrade ? 'default' : 'primary'}
type={promptUpgrade ? 'default' : 'primary'}
htmlType="submit"
disabled={
!canUpdateConfig || isUpdatingDatabaseForm || !databaseForm.formState.isDirty
Expand Down
Loading
Loading