Skip to content

Commit 8a5ad58

Browse files
dnywhjoshenlim
andauthored
chore(studio): replace CloseConfirmationModal with DiscardChangesConfirmationDialog (supabase#43430)
## What kind of change does this PR introduce? Form handling improvement. ## What is the current behavior? supabase#43201 standardised our discard changes behaviour with a shared hook and `DiscardChangesConfirmationDialog` component. But many forms and sheets still: 1. Don’t have any Discard-confirm close behaviour, making it too easy to make accidental discards 2. Use a more complicated, manually-created `CloseConfirmationModal` approach ## What is the new behavior? - Replaced all instances of `#2` above that had `CloseConfirmationModal` with `DiscardChangesConfirmationDialog` and its hook - Improved design system documentation around dirty form dismissal | Before | After | | --- | --- | | <img width="987" height="569" alt="Mercor Apexroles Foo Supabase-9A40EC7C-F335-4B26-B567-450FC0845463" src="https://github.com/user-attachments/assets/363bed82-34d2-4cc8-9164-6d18cfdbdbbc" /> | <img width="987" height="569" alt="Mercor Apexroles Foo Supabase-F427F1FA-DECC-4194-B663-A9E5A6F285A1" src="https://github.com/user-attachments/assets/d49fafdc-a5c2-46df-9b67-ec42bacbe716" /> | ## To test Try editing values these sheets in staging, then blurring the sheet or pressing `esc`: - CreateQueueSheet.tsx - CronJobsTab.tsx - CronJobPage.tsx - EditWrapperSheet.tsx - OverviewTab.tsx - WrappersTab.tsx - CreateFunction/index.tsx - EditHookPanel.tsx - TriggerSheet.tsx - SidePanelEditor.tsx - EditSecretSheet.tsx - PolicyEditorModal/index.tsx - PolicyEditorPanel/index.tsx ## Still to come - [ ] Incrementally take on `#1`: implement `DiscardChangesConfirmationDialog` and its hook in sheets or dialog forms that have no dirty form dismissal handling --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
1 parent a832d62 commit 8a5ad58

25 files changed

Lines changed: 718 additions & 721 deletions

File tree

apps/design-system/__registry__/index.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,17 @@ export const Index: Record<string, any> = {
12921292
subcategory: "undefined",
12931293
chunks: []
12941294
},
1295+
"sheet-confirm-on-close-demo": {
1296+
name: "sheet-confirm-on-close-demo",
1297+
type: "components:example",
1298+
registryDependencies: ["alert-dialog","button","input","label","separator","sheet"],
1299+
component: React.lazy(() => import("@/registry/default/example/sheet-confirm-on-close-demo")),
1300+
source: "",
1301+
files: ["registry/default/example/sheet-confirm-on-close-demo.tsx"],
1302+
category: "undefined",
1303+
subcategory: "undefined",
1304+
chunks: []
1305+
},
12951306
"sheet-demo": {
12961307
name: "sheet-demo",
12971308
type: "components:example",

apps/design-system/content/docs/fragments/confirmation-modal.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Use Confirmation Modal when the user needs extra context to make a decision, suc
1010

1111
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.
1212

13-
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.
13+
For dirty-form dismissal in dialogs/sheets, use the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) instead of `ConfirmationModal`. Avoid creating custom wrapper components for this flow; wire `modalProps` from `useConfirmOnClose` directly into `DiscardChangesConfirmationDialog`.
1414

1515
<ComponentPreview name="confirmation-modal-demo" peekCode wide />
1616

apps/design-system/content/docs/ui-patterns/modality.mdx

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,6 @@ We have two main ways of handling modality:
1818

1919
As a general rule: use dialogs for short, focused tasks and use sheets for longer forms or more detailed views.
2020

21-
### Dirty form dismissal pattern
22-
23-
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).
24-
25-
If the form is clean, close immediately. If the form has unsaved changes, show a discard-confirmation dialog instead of closing immediately.
26-
27-
This pattern is implemented in Studio with `useConfirmOnClose` plus `DiscardChangesConfirmationDialog`.
28-
29-
- **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.
30-
- **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`.
31-
- **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.
32-
3321
## Dialogs
3422

3523
Dialogs are centered overlays used for short, focused tasks. All dialogs should follow these best practices:
@@ -53,22 +41,6 @@ There are quite a few dialog components, each suited to a different task or cont
5341

5442
<ComponentPreview name="alert-dialog-demo" />
5543

56-
#### Discard changes confirmation dialog (pattern)
57-
58-
For dirty form dismissal, use a short discard-confirmation dialog after a close attempt instead of disabling dismissal entirely.
59-
60-
- The primary form remains in a `Dialog` or `Sheet` (dismissible).
61-
- Closing is intercepted only when the form is dirty.
62-
- The follow-up confirmation is an `AlertDialog` pattern (`DiscardChangesConfirmationDialog` in Studio).
63-
64-
Typical flow:
65-
66-
1. User attempts to close the dialog/sheet (backdrop, Escape, close icon, or `Cancel`)
67-
2. If the form is clean, close immediately
68-
3. If the form is dirty, show discard confirmation
69-
4. `Keep editing` returns to the form
70-
5. `Discard changes` closes and resets the form
71-
7244
#### Text Confirm Dialog
7345

7446
[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.
@@ -102,3 +74,53 @@ Sheets are dialogs presented as side panels. Use them for content that is larger
10274
[Sheet](../components/sheet) is modal by default, blocking interaction with the underlying page.
10375

10476
<ComponentPreview name="sheet-demo" />
77+
78+
## Best practices
79+
80+
### Dirty form dismissal
81+
82+
When a dialog or sheet contains a form, keep all normal dismissal affordances enabled (backdrop click, Escape key, close icon, and footer `Cancel` button).
83+
84+
Decision flow:
85+
86+
1. User attempts to close the dialog/sheet.
87+
2. If the form is clean, close immediately.
88+
3. If the form is dirty, show a discard-confirmation dialog.
89+
4. `Keep editing` returns to the form.
90+
5. `Discard changes` closes and resets the form.
91+
92+
Implementation checklist:
93+
94+
- Intercept close attempts from `onOpenChange`.
95+
- Route footer `Cancel` through the same close guard.
96+
- Render a separate discard confirmation dialog when dirty.
97+
- Keep `Cancel` non-destructive; use `Discard`/`Discard changes` for one-click destructive exits.
98+
- Guard controlled close attempts only; do not try to block route changes or arbitrary unmounts.
99+
100+
Studio implementation (preferred in Studio code):
101+
102+
```tsx
103+
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
104+
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
105+
106+
const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
107+
checkIsDirty: () => form.formState.isDirty,
108+
onClose,
109+
})
110+
111+
<Sheet open={visible} onOpenChange={handleOpenChange}>
112+
...
113+
<Button type="default" onClick={confirmOnClose}>
114+
Cancel
115+
</Button>
116+
...
117+
<DiscardChangesConfirmationDialog {...modalProps} />
118+
</Sheet>
119+
```
120+
121+
Generic implementation (outside Studio):
122+
123+
- If Studio-only helpers are unavailable, recreate the same behavior with `AlertDialog`.
124+
- The demo below shows the same flow and API shape (`confirmOnClose`, `handleOpenChange`, `modalProps`).
125+
126+
<ComponentPreview name="sheet-confirm-on-close-demo" />
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+
import {
3+
AlertDialog,
4+
AlertDialogAction,
5+
AlertDialogCancel,
6+
AlertDialogContent,
7+
AlertDialogDescription,
8+
AlertDialogFooter,
9+
AlertDialogHeader,
10+
AlertDialogTitle,
11+
Button,
12+
Input_Shadcn_ as Input,
13+
Label_Shadcn_ as Label,
14+
Separator,
15+
Sheet,
16+
SheetContent,
17+
SheetFooter,
18+
SheetHeader,
19+
SheetSection,
20+
SheetTitle,
21+
} from 'ui'
22+
23+
interface EndpointValues {
24+
endpointUrl: string
25+
secretHeader: string
26+
}
27+
28+
interface ConfirmOnCloseModalProps {
29+
visible: boolean
30+
onClose: () => void
31+
onCancel: () => void
32+
}
33+
34+
const defaultValues: EndpointValues = {
35+
endpointUrl: '',
36+
secretHeader: '',
37+
}
38+
39+
const useConfirmOnClose = ({
40+
checkIsDirty,
41+
onClose,
42+
}: {
43+
checkIsDirty: () => boolean
44+
onClose: () => void
45+
}) => {
46+
const [visible, setVisible] = useState(false)
47+
48+
const confirmOnClose = useCallback(() => {
49+
if (checkIsDirty()) {
50+
setVisible(true)
51+
return
52+
}
53+
54+
onClose()
55+
}, [checkIsDirty, onClose])
56+
57+
const handleOpenChange = useCallback(
58+
(open: boolean) => {
59+
if (!open) {
60+
confirmOnClose()
61+
}
62+
},
63+
[confirmOnClose]
64+
)
65+
66+
const onConfirm = useCallback(() => {
67+
setVisible(false)
68+
onClose()
69+
}, [onClose])
70+
71+
const onCancel = useCallback(() => {
72+
setVisible(false)
73+
}, [])
74+
75+
const modalProps: ConfirmOnCloseModalProps = useMemo(
76+
() => ({
77+
visible,
78+
onClose: onConfirm,
79+
onCancel,
80+
}),
81+
[visible, onConfirm, onCancel]
82+
)
83+
84+
return {
85+
confirmOnClose,
86+
handleOpenChange,
87+
modalProps,
88+
}
89+
}
90+
91+
const DiscardChangesAlertDialog = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => {
92+
const isConfirmingRef = useRef(false)
93+
94+
useEffect(() => {
95+
if (visible) {
96+
isConfirmingRef.current = false
97+
}
98+
}, [visible])
99+
100+
const handleConfirm = useCallback(() => {
101+
isConfirmingRef.current = true
102+
onClose()
103+
}, [onClose])
104+
105+
const handleOpenChange = useCallback(
106+
(open: boolean) => {
107+
if (open) return
108+
109+
if (isConfirmingRef.current) {
110+
isConfirmingRef.current = false
111+
return
112+
}
113+
114+
onCancel()
115+
},
116+
[onCancel]
117+
)
118+
119+
return (
120+
<AlertDialog open={visible} onOpenChange={handleOpenChange}>
121+
<AlertDialogContent>
122+
<AlertDialogHeader>
123+
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
124+
<AlertDialogDescription>
125+
Any unsaved changes to this endpoint will be lost.
126+
</AlertDialogDescription>
127+
</AlertDialogHeader>
128+
<AlertDialogFooter>
129+
<AlertDialogCancel>Keep editing</AlertDialogCancel>
130+
<AlertDialogAction variant="danger" onClick={handleConfirm}>
131+
Discard changes
132+
</AlertDialogAction>
133+
</AlertDialogFooter>
134+
</AlertDialogContent>
135+
</AlertDialog>
136+
)
137+
}
138+
139+
export default function SheetConfirmOnCloseDemo() {
140+
const [open, setOpen] = useState(false)
141+
const [savedValues, setSavedValues] = useState<EndpointValues>(defaultValues)
142+
const [draftValues, setDraftValues] = useState<EndpointValues>(defaultValues)
143+
144+
const isDirty = useMemo(
145+
() =>
146+
draftValues.endpointUrl !== savedValues.endpointUrl ||
147+
draftValues.secretHeader !== savedValues.secretHeader,
148+
[draftValues, savedValues]
149+
)
150+
151+
const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
152+
checkIsDirty: () => isDirty,
153+
onClose: () => {
154+
setDraftValues(savedValues)
155+
setOpen(false)
156+
},
157+
})
158+
159+
const openSheet = () => {
160+
setDraftValues(savedValues)
161+
setOpen(true)
162+
}
163+
164+
const saveChanges = () => {
165+
setSavedValues(draftValues)
166+
setOpen(false)
167+
}
168+
169+
return (
170+
<>
171+
<Button type="default" onClick={openSheet}>
172+
Open endpoint sheet
173+
</Button>
174+
175+
<Sheet open={open} onOpenChange={handleOpenChange}>
176+
<SheetContent className="flex flex-col gap-0">
177+
<SheetHeader>
178+
<SheetTitle>Edit endpoint</SheetTitle>
179+
</SheetHeader>
180+
<Separator />
181+
<SheetSection className="space-y-4">
182+
<div className="space-y-2">
183+
<Label htmlFor="endpoint-url">Endpoint URL</Label>
184+
<Input
185+
id="endpoint-url"
186+
value={draftValues.endpointUrl}
187+
placeholder="https://api.example.com/webhooks/supabase"
188+
onChange={(event) =>
189+
setDraftValues((current) => ({
190+
...current,
191+
endpointUrl: event.target.value,
192+
}))
193+
}
194+
/>
195+
</div>
196+
<div className="space-y-2">
197+
<Label htmlFor="secret-header">Secret header</Label>
198+
<Input
199+
id="secret-header"
200+
value={draftValues.secretHeader}
201+
placeholder="Bearer top-secret-value"
202+
onChange={(event) =>
203+
setDraftValues((current) => ({
204+
...current,
205+
secretHeader: event.target.value,
206+
}))
207+
}
208+
/>
209+
</div>
210+
</SheetSection>
211+
<Separator />
212+
<SheetFooter>
213+
<Button type="default" onClick={confirmOnClose}>
214+
Cancel
215+
</Button>
216+
<Button onClick={saveChanges} disabled={!isDirty}>
217+
Save changes
218+
</Button>
219+
</SheetFooter>
220+
</SheetContent>
221+
</Sheet>
222+
<DiscardChangesAlertDialog {...modalProps} />
223+
</>
224+
)
225+
}

apps/design-system/registry/examples.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,12 @@ export const examples: Registry = [
761761
registryDependencies: ['separator'],
762762
files: ['example/separator-demo.tsx'],
763763
},
764+
{
765+
name: 'sheet-confirm-on-close-demo',
766+
type: 'components:example',
767+
registryDependencies: ['alert-dialog', 'button', 'input', 'label', 'separator', 'sheet'],
768+
files: ['example/sheet-confirm-on-close-demo.tsx'],
769+
},
764770
{
765771
name: 'sheet-demo',
766772
type: 'components:example',

apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import type { PostgresPolicy } from '@supabase/postgres-meta'
2-
import { has, isEmpty, isEqual } from 'lodash'
3-
41
import { ident } from '@supabase/pg-meta/src/pg-format'
2+
import type { PostgresPolicy } from '@supabase/postgres-meta'
53
import { generateSqlPolicy } from 'data/ai/sql-policy-mutation'
64
import type { CreatePolicyBody } from 'data/database-policies/database-policy-create-mutation'
75
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
6+
import { has, isEmpty, isEqual } from 'lodash'
7+
88
import {
99
PolicyFormField,
1010
PolicyForReview,
@@ -19,7 +19,7 @@ import {
1919

2020
export const createSQLPolicy = (
2121
policyFormFields: PolicyFormField,
22-
originalPolicyFormFields: PostgresPolicy
22+
originalPolicyFormFields?: PostgresPolicy
2323
) => {
2424
const { definition, check } = policyFormFields
2525
const formattedPolicyFormFields = {
@@ -32,7 +32,7 @@ export const createSQLPolicy = (
3232
check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check,
3333
}
3434

35-
if (isEmpty(originalPolicyFormFields)) {
35+
if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) {
3636
return createSQLStatementForCreatePolicy(formattedPolicyFormFields)
3737
}
3838

0 commit comments

Comments
 (0)