Skip to content

Commit b62a3e0

Browse files
committed
improvement(settings): shared save/discard + unsaved-changes guard
Consolidate every editable settings surface onto one stack instead of per-page custom logic: - SaveDiscardActions: the canonical dirty-gated Discard+Save chip pair - useSettingsUnsavedGuard: syncs local dirty into useSettingsDirtyStore (so the sidebar section-switch confirm applies) + provides guardBack/UnsavedChangesModal for detail sub-views' back chip - useSettingsBeforeUnload: a single beforeunload in the settings shell Migrate whitelabeling, sso, access-control group-detail, data-retention, and secrets-manager (drops its duplicate beforeunload). Deletes the hand-rolled 'Unsaved changes' modals; the leave-confirm standardizes on Keep editing / Discard. Documents the pattern in the settings rule + add-settings-page skill.
1 parent 0df4045 commit b62a3e0

11 files changed

Lines changed: 267 additions & 172 deletions

File tree

.claude/rules/sim-settings-pages.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,46 @@ Adding a new settings page:
101101
Conditional items become array spreads: `...(canManage ? [{…}] : [])`. Never
102102
hand-roll the `<DropdownMenu>` + `<MoreHorizontal>` trigger per page.
103103

104+
## Save / Discard + unsaved-changes guard
105+
106+
Any settings surface with editable state uses **one** shared stack — never
107+
hand-roll a Save button, a Discard button, a `beforeunload`, or an "Unsaved
108+
changes" modal:
109+
110+
- **`SaveDiscardActions`** (`…/components/save-discard-actions/save-discard-actions`)
111+
— the canonical dirty-gated **Discard + Save** chip pair. Renders nothing when
112+
`!dirty`; otherwise a fragment so it composes beside sibling chips (a detail
113+
view's Delete / Remove override, a Share chip). Props: `dirty`, `saving`,
114+
`onSave`, `onDiscard`, `saveDisabled?`, `saveLabel?`, `savingLabel?`. Put it in
115+
the `SettingsPanel actions` slot (top-level pages) or the detail header bar.
116+
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
117+
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
118+
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
119+
apply for free) and returns `{ showUnsavedModal, setShowUnsavedModal, guardBack,
120+
confirmDiscard }` for a detail view's **in-view back** chip.
121+
- **Top-level pages** (whitelabeling, sso): call it **unassigned**
122+
`useSettingsUnsavedGuard({ isDirty: hasChanges })` — they only need the
123+
store-sync; the sidebar/`beforeunload` do the rest.
124+
- **Detail sub-views** (data-retention, access-control group-detail): route the
125+
back chip through `onClick={() => guard.guardBack(closeFn)}` and render the
126+
shared `<UnsavedChangesModal open={guard.showUnsavedModal}
127+
onOpenChange={guard.setShowUnsavedModal} onDiscard={guard.confirmDiscard} />`
128+
(from `@/app/workspace/[workspaceId]/components/credential-detail`). The
129+
in-view header **Discard** chip (via `SaveDiscardActions onDiscard`) is a
130+
*reset to original* — distinct from the back-confirm's discard, which leaves.
131+
- **`useSettingsBeforeUnload`** is mounted **once** in the settings shell
132+
(`settings/[section]/settings.tsx`) — never add a per-page `beforeunload`.
133+
- **Dirty *computation* stays local** (shapes differ: field-compare vs
134+
normalize+stringify) — only how dirty is *consumed* is shared. Derive it (a
135+
`const`/`useMemo`), never store it in `useState`.
136+
- **CRITICAL — rules of hooks:** call `useSettingsUnsavedGuard(...)`
137+
**unconditionally, before every early-return gate** (entitlement / loading /
138+
not-entitled `return <SettingsEmptyState>`). A hook placed after a gate is
139+
skipped on gated renders and crashes.
140+
- The route-based credential detail keeps its own `useUnsavedChangesGuard` (it
141+
guards real `router.push` navigation + browser Back via a history sentinel);
142+
it already shares `UnsavedChangesModal`, so copy stays unified.
143+
104144
## Detail sub-views (the one exception)
105145

106146
A drill-down view reached from a list row (selected MCP server, workflow MCP
@@ -119,5 +159,6 @@ A settings page is design-system-clean when:
119159
- [ ] Header chips are in `actions`; a standalone search is in the `search` prop.
120160
- [ ] Its `NavigationItem` has an accurate, consistent-length `description`.
121161
- [ ] Detail sub-views and entitlement/loading gates keep their own chrome (intentional).
162+
- [ ] If it has editable state: Save/Discard go through `SaveDiscardActions`, dirty is wired via `useSettingsUnsavedGuard` (called before any early-return gate), and there is **no** hand-rolled Save button / `beforeunload` / "Unsaved changes" modal.
122163
- [ ] No business logic, handlers, or conditional rendering changed by the migration.
123164
- [ ] `tsc`, `biome`, and the page's tests pass.

.claude/skills/add-settings-page/SKILL.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@ Key paths:
2929
bar, scroll region, content column, or title block. Put header buttons in
3030
`actions`, a standalone search in `search={{ value, onChange, placeholder }}`,
3131
and the page content as `children`. Modals go beside the panel inside a `<>`.
32-
4. **Verify:** `cd apps/sim && bunx tsc --noEmit`; `bunx biome check --write <file>`.
32+
4. **If the page has editable state**, wire the shared save/discard stack — put
33+
`SaveDiscardActions` (dirty-gated Discard+Save chips) in `actions`, and call
34+
`useSettingsUnsavedGuard({ isDirty })` **before any early-return gate**.
35+
Detail sub-views additionally route the back chip through
36+
`guard.guardBack(closeFn)` and render the shared `UnsavedChangesModal`. Never
37+
hand-roll a Save button, a `beforeunload`, or an "Unsaved changes" modal —
38+
they're centralized. See the "Save / Discard + unsaved-changes guard" section
39+
in `.claude/rules/sim-settings-pages.md`.
40+
5. **Verify:** `cd apps/sim && bunx tsc --noEmit`; `bunx biome check --write <file>`.
3341

3442
## Mode B — Audit existing settings pages
3543

@@ -44,6 +52,11 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa
4452
`git grep -n "text-\[var(--text-body)\] text-lg" -- 'apps/sim/**/settings/' 'apps/sim/ee/'`
4553
3. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an
4654
accurate `description` of consistent length with its peers.
55+
- Editable pages: confirm Save/Discard go through `SaveDiscardActions` and
56+
dirty is wired via `useSettingsUnsavedGuard` (called before early-return
57+
gates) — flag any hand-rolled Save button, `beforeunload`, or unsaved modal.
58+
`git grep -n "beforeunload" -- 'apps/sim/**/settings/' 'apps/sim/ee/'`
59+
should only hit the centralized `use-settings-before-unload.ts`.
4760
4. When migrating a page, change ONLY the structural shell→`SettingsPanel` swap:
4861
move header chips to `actions`, the standalone search to `search`, delete the
4962
`<h1>` title block, replace the three closing `</div>` (column/scroll/shell)

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useSession } from '@/lib/auth/auth-client'
77
import { captureEvent } from '@/lib/posthog/client'
88
import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
99
import { SettingsSectionProvider } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
10+
import { useSettingsBeforeUnload } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload'
1011
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
1112
import {
1213
isBillingEnabled,
@@ -105,6 +106,8 @@ export function SettingsPage({ section }: SettingsPageProps) {
105106
const { data: session, isPending: sessionLoading } = useSession()
106107
const posthog = usePostHog()
107108

109+
useSettingsBeforeUnload()
110+
108111
const isAdminRole = session?.user?.role === 'admin'
109112
const normalizedSection: SettingsSection =
110113
(section as string) === 'subscription' ? 'billing' : section
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Chip } from '@/components/emcn'
2+
3+
interface SaveDiscardActionsProps {
4+
/** When false, renders nothing. */
5+
dirty: boolean
6+
/** A save is in flight — disables both chips and shows `savingLabel` on Save. */
7+
saving: boolean
8+
onSave: () => void
9+
onDiscard: () => void
10+
/** Disables Save independently of `saving` (e.g. validation errors, empty required field). */
11+
saveDisabled?: boolean
12+
savingLabel?: string
13+
saveLabel?: string
14+
}
15+
16+
/**
17+
* The canonical dirty-gated Discard + Save chip pair for settings surfaces.
18+
* Renders nothing when not `dirty`; otherwise a fragment (no wrapper) so it
19+
* composes beside sibling chips in a `SettingsPanel` actions slot or a detail
20+
* header bar (e.g. group-detail's Delete, data-retention's Remove override).
21+
*/
22+
export function SaveDiscardActions({
23+
dirty,
24+
saving,
25+
onSave,
26+
onDiscard,
27+
saveDisabled = false,
28+
savingLabel = 'Saving...',
29+
saveLabel = 'Save',
30+
}: SaveDiscardActionsProps) {
31+
if (!dirty) return null
32+
return (
33+
<>
34+
<Chip onClick={onDiscard} disabled={saving}>
35+
Discard
36+
</Chip>
37+
<Chip variant='primary' onClick={onSave} disabled={saving || saveDisabled}>
38+
{saving ? savingLabel : saveLabel}
39+
</Chip>
40+
</>
41+
)
42+
}

apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -501,16 +501,6 @@ export function SecretsManager() {
501501
})
502502
}, [])
503503

504-
useEffect(() => {
505-
const handler = (e: BeforeUnloadEvent) => {
506-
e.preventDefault()
507-
}
508-
if (hasChanges) {
509-
window.addEventListener('beforeunload', handler)
510-
}
511-
return () => window.removeEventListener('beforeunload', handler)
512-
}, [hasChanges])
513-
514504
/**
515505
* Navigation guard: intercept link clicks in the capture phase before
516506
* Next.js App Router processes them. This is needed because Next.js
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useEffect } from 'react'
2+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
3+
4+
/**
5+
* Registers a single `beforeunload` guard for the whole settings surface,
6+
* active only while some section reports unsaved changes via
7+
* `useSettingsDirtyStore`. Mounted once in the settings shell so individual
8+
* pages never register their own.
9+
*/
10+
export function useSettingsBeforeUnload() {
11+
const isDirty = useSettingsDirtyStore((s) => s.isDirty)
12+
13+
useEffect(() => {
14+
if (!isDirty) return
15+
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
16+
event.preventDefault()
17+
}
18+
window.addEventListener('beforeunload', handleBeforeUnload)
19+
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
20+
}, [isDirty])
21+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
3+
4+
interface UseSettingsUnsavedGuardParams {
5+
isDirty: boolean
6+
}
7+
8+
interface SettingsUnsavedGuard {
9+
showUnsavedModal: boolean
10+
setShowUnsavedModal: (open: boolean) => void
11+
/** Run `onLeave` immediately when clean; when dirty, open the confirm modal and defer it. */
12+
guardBack: (onLeave: () => void) => void
13+
/** Confirmed discard — close the modal and run the deferred leave action. */
14+
confirmDiscard: () => void
15+
}
16+
17+
/**
18+
* Wires a settings surface's local dirty state into the shared
19+
* `useSettingsDirtyStore`, so the sidebar's section-switch confirmation and the
20+
* centralized `beforeunload` both apply without per-page wiring. Also provides
21+
* an in-view back/close guard (`guardBack` + the shared `UnsavedChangesModal`)
22+
* for detail sub-views whose "back" is an in-component state change rather than
23+
* a route navigation.
24+
*/
25+
export function useSettingsUnsavedGuard({
26+
isDirty,
27+
}: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard {
28+
const setDirty = useSettingsDirtyStore((s) => s.setDirty)
29+
const reset = useSettingsDirtyStore((s) => s.reset)
30+
const isDirtyRef = useRef(isDirty)
31+
const pendingLeaveRef = useRef<(() => void) | null>(null)
32+
const [showUnsavedModal, setShowUnsavedModal] = useState(false)
33+
34+
useEffect(() => {
35+
isDirtyRef.current = isDirty
36+
setDirty(isDirty)
37+
}, [isDirty, setDirty])
38+
39+
useEffect(() => {
40+
return () => reset()
41+
}, [reset])
42+
43+
const guardBack = useCallback((onLeave: () => void) => {
44+
if (isDirtyRef.current) {
45+
pendingLeaveRef.current = onLeave
46+
setShowUnsavedModal(true)
47+
} else {
48+
onLeave()
49+
}
50+
}, [])
51+
52+
const confirmDiscard = useCallback(() => {
53+
setShowUnsavedModal(false)
54+
pendingLeaveRef.current?.()
55+
pendingLeaveRef.current = null
56+
}, [])
57+
58+
return { showUnsavedModal, setShowUnsavedModal, guardBack, confirmDiscard }
59+
}

apps/sim/ee/access-control/components/group-detail.tsx

Lines changed: 19 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,15 @@ import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
2929
import { cn } from '@/lib/core/utils/cn'
3030
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
3131
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
32+
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
3233
import {
3334
MemberAvatar,
3435
MemberRow,
3536
} from '@/app/workspace/[workspaceId]/settings/components/member-list'
3637
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
38+
import { SaveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
3739
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
40+
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
3841
import { getAllBlocks } from '@/blocks'
3942
import type { BlockConfig } from '@/blocks/types'
4043
import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
@@ -603,7 +606,6 @@ export function GroupDetail({
603606
const [addMembersError, setAddMembersError] = useState<string | null>(null)
604607
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(() => new Set())
605608
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
606-
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
607609

608610
const { data: members = [], isPending: membersLoading } = usePermissionGroupMembers(
609611
organizationId,
@@ -823,6 +825,7 @@ export function GroupDetail({
823825
const hasConfigChanges = useMemo(() => {
824826
return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig)
825827
}, [viewingGroup.config, editingConfig])
828+
const guard = useSettingsUnsavedGuard({ isDirty: hasConfigChanges })
826829

827830
const filteredProviders = useMemo(() => {
828831
if (!providerSearchTerm.trim()) return allProviderIds
@@ -1096,22 +1099,20 @@ export function GroupDetail({
10961099
}))
10971100
}, [])
10981101

1099-
/** Persists the editing buffer. Returns whether the save succeeded so callers can decide whether to navigate away. */
1100-
const handleSaveConfig = useCallback(async (): Promise<boolean> => {
1102+
/** Persists the editing buffer. */
1103+
const handleSaveConfig = useCallback(async () => {
11011104
try {
11021105
await updatePermissionGroup.mutateAsync({
11031106
id: viewingGroup.id,
11041107
organizationId,
11051108
config: editingConfig,
11061109
})
11071110
setViewingGroup((prev) => ({ ...prev, config: editingConfig }))
1108-
return true
11091111
} catch (error) {
11101112
logger.error('Failed to update config', error)
11111113
toast.error("Couldn't save changes", {
11121114
description: getErrorMessage(error, 'Please try again in a moment.'),
11131115
})
1114-
return false
11151116
}
11161117
}, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup])
11171118

@@ -1120,12 +1121,8 @@ export function GroupDetail({
11201121
}, [viewingGroup.config])
11211122

11221123
const handleBack = useCallback(() => {
1123-
if (hasConfigChanges) {
1124-
setShowUnsavedChanges(true)
1125-
return
1126-
}
1127-
onBack()
1128-
}, [hasConfigChanges, onBack])
1124+
guard.guardBack(onBack)
1125+
}, [guard.guardBack, onBack])
11291126

11301127
const handleScopeChange = useCallback(
11311128
async (workspaceIds: string[]) => {
@@ -1270,20 +1267,12 @@ export function GroupDetail({
12701267
Access Control
12711268
</Chip>
12721269
<div className='flex items-center gap-1'>
1273-
{hasConfigChanges && (
1274-
<>
1275-
<Chip onClick={handleDiscardConfig} disabled={updatePermissionGroup.isPending}>
1276-
Discard
1277-
</Chip>
1278-
<Chip
1279-
variant='primary'
1280-
onClick={handleSaveConfig}
1281-
disabled={updatePermissionGroup.isPending}
1282-
>
1283-
{updatePermissionGroup.isPending ? 'Saving...' : 'Save'}
1284-
</Chip>
1285-
</>
1286-
)}
1270+
<SaveDiscardActions
1271+
dirty={hasConfigChanges}
1272+
saving={updatePermissionGroup.isPending}
1273+
onSave={handleSaveConfig}
1274+
onDiscard={handleDiscardConfig}
1275+
/>
12871276
<Chip
12881277
variant='destructive'
12891278
onClick={() => setShowDeleteConfirm(true)}
@@ -1689,45 +1678,11 @@ export function GroupDetail({
16891678
}}
16901679
/>
16911680

1692-
<ChipModal
1693-
open={showUnsavedChanges}
1694-
onOpenChange={setShowUnsavedChanges}
1695-
size='sm'
1696-
srTitle='Unsaved Changes'
1697-
>
1698-
<ChipModalHeader onClose={() => setShowUnsavedChanges(false)}>
1699-
Unsaved Changes
1700-
</ChipModalHeader>
1701-
<ChipModalBody>
1702-
<p className='px-2 text-[var(--text-secondary)] text-sm'>
1703-
You have unsaved changes. Do you want to save them before leaving?
1704-
</p>
1705-
</ChipModalBody>
1706-
<ChipModalFooter
1707-
onCancel={() => setShowUnsavedChanges(false)}
1708-
secondaryActions={[
1709-
{
1710-
label: 'Discard Changes',
1711-
onClick: () => {
1712-
setShowUnsavedChanges(false)
1713-
onBack()
1714-
},
1715-
variant: 'destructive',
1716-
},
1717-
]}
1718-
primaryAction={{
1719-
label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes',
1720-
onClick: async () => {
1721-
const saved = await handleSaveConfig()
1722-
if (saved) {
1723-
setShowUnsavedChanges(false)
1724-
onBack()
1725-
}
1726-
},
1727-
disabled: updatePermissionGroup.isPending,
1728-
}}
1729-
/>
1730-
</ChipModal>
1681+
<UnsavedChangesModal
1682+
open={guard.showUnsavedModal}
1683+
onOpenChange={guard.setShowUnsavedModal}
1684+
onDiscard={guard.confirmDiscard}
1685+
/>
17311686
</>
17321687
)
17331688
}

0 commit comments

Comments
 (0)