Skip to content

Commit 2cdda6c

Browse files
committed
improvement(settings): one header action order across detail pages
Detail headers disagreed on where Delete sits. The skills page reads `Share → Delete → Discard → Save`, but every SettingsPanel page spread saveDiscardActions() first and appended Delete, rendering it to the RIGHT of the primary chip: sandboxes, custom tools, custom blocks, permission groups and data retention all did this. Fixed in the shell rather than at nine callsites. orderHeaderActions() ranks actions — secondary, then `id:'discard'`, then `variant:'primary'` — stably within each band, so writing the array the natural way now produces the right header and a new detail page cannot get it wrong. This generalizes past Save: workflow MCP servers' `Add workflows` primary is now right-most with Delete before it, instead of the reverse. The ranking has to survive one trap. The shell routes onSelect through configRef.current.actions[index] to avoid stale closures, so reordering the render without preserving the source index would bind every chip to the wrong handler — clicking Delete would Save. orderHeaderActions carries {action,index} pairs; settings-header-shell.test.tsx pins that at the render level, including the conditional-Discard case where a missing action shifts every index. Delete is also now a plain chip on the nine resource-detail headers, matching skills, each with a stable `id:'delete'` (three lacked one, so the chip remounted when its label flipped to Deleting...). `variant:'destructive'` is kept for actions destructive at scale — Delete all passwords, Clear all browsing data, Sign out all members — which the confirm modal does not cover the way it covers removing the single resource you are looking at.
1 parent 03333ce commit 2cdda6c

13 files changed

Lines changed: 321 additions & 16 deletions

File tree

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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,46 @@ the email is the primary content; `components/permissions/member-row.tsx` render
247247
a 36px `getUserColor`-hashed avatar for member *management* rows that carry a name,
248248
an email, and a role control. Same shape, different job — do not merge them.
249249

250+
## Header action order
251+
252+
Every detail header reads left→right:
253+
254+
```
255+
← Back [secondary actions] → Delete → Discard → Save
256+
```
257+
258+
You do not have to get the array order right — `orderHeaderActions()` ranks them
259+
(secondary → `id:'discard'``variant:'primary'`), order-stable within each
260+
band, so spreading `saveDiscardActions()` first still renders Save last. Both
261+
action stacks apply it: `SettingsHeaderShell` and `SettingsActionChips`.
262+
Covered by `settings-header-order.test.ts` and `settings-header-shell.test.tsx`
263+
— the latter pins that a reordered chip still routes to its own handler.
264+
265+
Two consequences worth knowing:
266+
267+
- **The primary chip is always right-most**, and it is not always Save — on a
268+
page with no save state it is whatever the primary action is (`Add workflows`,
269+
`Import`). Delete still precedes it.
270+
- `CredentialDetailLayout` takes a `ReactNode`, so the chips you write directly
271+
are in your order — only what you route through `SettingsActionChips` /
272+
`SaveDiscardChips` is ranked. Put `<SaveDiscardChips>` last (skills, secrets,
273+
connected credentials already do).
274+
250275
## Deleting a resource
251276

252-
Delete lives in the **detail header**, as
253-
`{ text: 'Delete', variant: 'destructive', onSelect: … }` behind a
254-
`ChipConfirmModal` — never `textTone: 'error'`, never a bare `Chip`, and never
255-
unconfirmed. A list row does not carry Delete when the resource has a detail page.
277+
Delete lives in the **detail header**, as `{ id: 'delete', text: 'Delete',
278+
onSelect: … }` behind a `ChipConfirmModal` — a **plain chip**, never
279+
`textTone: 'error'`, and never unconfirmed. In a `SettingsPanel` header it is
280+
action *data*, never a hand-rolled `<Chip>`; only `CredentialDetailLayout`
281+
surfaces, which take a `ReactNode`, render one directly. Always set `id: 'delete'`; without a
282+
stable id the chip remounts when the label flips to `Deleting...`.
283+
284+
`variant: 'destructive'` is reserved for actions that are destructive at
285+
**scale**`Delete all` passwords, `Clear all` browsing data, `Sign out all
286+
members`. Removing the single resource you are already looking at is confirmed
287+
by the modal, so it does not also need a red chip.
288+
289+
A list row does not carry Delete when the resource has a detail page.
256290

257291
## Save / Discard + unsaved-changes guard
258292

@@ -344,5 +378,5 @@ A settings page is design-system-clean when:
344378
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
345379
- [ ] Decorative trailing content is in `badge`, not `trailing`.
346380
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
347-
- [ ] Delete is a `destructive` header action behind a `ChipConfirmModal`.
381+
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
348382
- [ ] `tsc`, `biome`, and the page's tests pass.

apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/password-detail/password-detail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ export function PasswordDetail({ credential, onBack, onForgotten }: PasswordDeta
133133
description='Saved on this device, encrypted. Chat can never read, choose, or type it.'
134134
actions={[
135135
{
136+
id: 'delete',
136137
text: 'Forget',
137-
variant: 'destructive' as const,
138138
onSelect: () => setConfirmingForget(true),
139139
disabled: busy,
140140
},

apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ export function CustomToolDetail({
221221
{
222222
id: 'delete',
223223
text: deleteTool.isPending ? 'Deleting...' : 'Delete',
224-
variant: 'destructive' as const,
225224
onSelect: () => setShowDeleteConfirm(true),
226225
disabled: deleteTool.isPending,
227226
},

apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,6 @@ export function MCP() {
448448
{
449449
id: 'delete',
450450
text: deletingServers.has(server.id) ? 'Deleting...' : 'Delete',
451-
variant: 'destructive' as const,
452451
onSelect: () => handleRemoveServer(server.id),
453452
disabled: deletingServers.has(server.id),
454453
},

apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,6 @@ export function Sandboxes() {
217217
{
218218
id: 'delete',
219219
text: deleteSandbox.isPending ? 'Deleting...' : 'Delete',
220-
variant: 'destructive' as const,
221220
onSelect: () => setShowDeleteConfirm(true),
222221
disabled: deleteSandbox.isPending,
223222
},

apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,6 @@ function ServerDetailView({
411411
{
412412
id: 'delete',
413413
text: isDeleting ? 'Deleting...' : 'Delete',
414-
variant: 'destructive' as const,
415414
onSelect: onDelete,
416415
disabled: isDeleting,
417416
},
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
3+
import type { SettingsAction } from '@/components/settings/settings-header'
4+
import { orderHeaderActions } from '@/components/settings/settings-header'
5+
6+
const noop = () => {}
7+
8+
/** Labels in the order the header renders them. */
9+
function rendered(actions: SettingsAction[]): string[] {
10+
return orderHeaderActions(actions).map(({ action }) => action.text)
11+
}
12+
13+
const save = (dirty: boolean) =>
14+
saveDiscardActions({ dirty, saving: false, onSave: noop, onDiscard: noop })
15+
16+
describe('orderHeaderActions', () => {
17+
it('puts Delete before Discard and Save no matter how the caller ordered them', () => {
18+
// The natural way to write this array — Save/Discard first, then Delete —
19+
// is what every settings detail page did, and it rendered Delete to the
20+
// right of the primary chip.
21+
const actions: SettingsAction[] = [
22+
...save(true),
23+
{ id: 'delete', text: 'Delete', onSelect: noop },
24+
]
25+
26+
expect(rendered(actions)).toEqual(['Delete', 'Discard', 'Save'])
27+
})
28+
29+
it('matches the skills detail header: secondary actions, then Delete, then Save', () => {
30+
const actions: SettingsAction[] = [
31+
{ text: 'Share', onSelect: noop },
32+
{ id: 'delete', text: 'Delete', onSelect: noop },
33+
...save(true),
34+
]
35+
36+
expect(rendered(actions)).toEqual(['Share', 'Delete', 'Discard', 'Save'])
37+
})
38+
39+
it('keeps Save right-most when there is nothing to discard', () => {
40+
const actions: SettingsAction[] = [
41+
...save(false),
42+
{ id: 'delete', text: 'Delete', onSelect: noop },
43+
]
44+
45+
expect(rendered(actions)).toEqual(['Delete', 'Save'])
46+
})
47+
48+
it('sends any primary action to the end, not just Save', () => {
49+
// Workflow MCP servers: Add workflows is the primary, Delete must precede it.
50+
const actions: SettingsAction[] = [
51+
{ text: 'Edit server', onSelect: noop },
52+
{ text: 'Add workflows', variant: 'primary', onSelect: noop },
53+
{ id: 'delete', text: 'Delete', onSelect: noop },
54+
]
55+
56+
expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows'])
57+
})
58+
59+
it('preserves caller order within a band', () => {
60+
const actions: SettingsAction[] = [
61+
{ text: 'Refresh', onSelect: noop },
62+
{ text: 'Edit', onSelect: noop },
63+
{ id: 'delete', text: 'Delete', onSelect: noop },
64+
]
65+
66+
expect(rendered(actions)).toEqual(['Refresh', 'Edit', 'Delete'])
67+
})
68+
69+
it('leaves a destructive bulk action left of the primary', () => {
70+
// Passwords: `Delete all` is destructive but must not outrank `Import`.
71+
// This is what keeps a red chip from becoming the right-most control.
72+
const actions: SettingsAction[] = [
73+
{ text: 'Delete all', variant: 'destructive', onSelect: noop },
74+
{ text: 'Import', variant: 'primary', onSelect: noop },
75+
]
76+
77+
expect(rendered(actions)).toEqual(['Delete all', 'Import'])
78+
})
79+
80+
it('ranks a destructive action alongside secondary ones, not after Discard', () => {
81+
const actions: SettingsAction[] = [
82+
...save(true),
83+
{ text: 'Sign out all members', variant: 'destructive', onSelect: noop },
84+
]
85+
86+
expect(rendered(actions)).toEqual(['Sign out all members', 'Discard', 'Save'])
87+
})
88+
89+
it('treats primary as the stronger signal when an action is both', () => {
90+
const actions: SettingsAction[] = [
91+
{ text: 'Other', onSelect: noop },
92+
{ id: 'discard', text: 'Odd', variant: 'primary', onSelect: noop },
93+
]
94+
95+
expect(rendered(actions)).toEqual(['Other', 'Odd'])
96+
})
97+
98+
it('carries each action original index so ref-routed handlers stay bound', () => {
99+
const actions: SettingsAction[] = [
100+
...save(true), // indices 0 (Discard), 1 (Save)
101+
{ id: 'delete', text: 'Delete', onSelect: noop }, // index 2
102+
]
103+
104+
expect(orderHeaderActions(actions).map(({ action, index }) => [action.text, index])).toEqual([
105+
['Delete', 2],
106+
['Discard', 0],
107+
['Save', 1],
108+
])
109+
})
110+
111+
it('tolerates an absent or empty action list', () => {
112+
expect(orderHeaderActions(undefined)).toEqual([])
113+
expect(orderHeaderActions([])).toEqual([])
114+
})
115+
116+
it('does not mutate the caller array', () => {
117+
// The shell sorts a prop read off a live ref; reordering it in place would
118+
// renumber the indices the handlers are routed through.
119+
const actions: SettingsAction[] = [
120+
...save(true),
121+
{ id: 'delete', text: 'Delete', onSelect: noop },
122+
]
123+
const before = actions.map((a) => a.text)
124+
125+
orderHeaderActions(actions)
126+
127+
expect(actions.map((a) => a.text)).toEqual(before)
128+
})
129+
})
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* The shell renders header actions in ranked order but routes every handler
5+
* through `configRef.current.actions[index]` to dodge stale closures. Those two
6+
* facts fight each other: if the reordered render ever renumbered the indices,
7+
* clicking Delete would invoke Save. These tests pin the pairing at the render
8+
* level, which the pure-function tests cannot reach.
9+
*/
10+
import { act } from 'react'
11+
import { createRoot, type Root } from 'react-dom/client'
12+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
14+
import type { SettingsAction } from '@/components/settings/settings-header'
15+
import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header'
16+
import { SettingsPanel } from '@/components/settings/settings-panel'
17+
18+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
19+
20+
let container: HTMLDivElement
21+
let root: Root
22+
23+
beforeEach(() => {
24+
container = document.createElement('div')
25+
document.body.appendChild(container)
26+
root = createRoot(container)
27+
})
28+
29+
afterEach(() => {
30+
act(() => root.unmount())
31+
container.remove()
32+
vi.clearAllMocks()
33+
})
34+
35+
function renderHeader(actions: SettingsAction[]) {
36+
act(() => {
37+
root.render(
38+
<SettingsHeaderProvider>
39+
<SettingsHeaderShell>
40+
<SettingsPanel title='Thing' actions={actions}>
41+
<div />
42+
</SettingsPanel>
43+
</SettingsHeaderShell>
44+
</SettingsHeaderProvider>
45+
)
46+
})
47+
}
48+
49+
/** Header chips in rendered (left→right) order. */
50+
function chipLabels(): string[] {
51+
return [...container.querySelectorAll('header button, div button')]
52+
.map((node) => node.textContent?.trim() ?? '')
53+
.filter(Boolean)
54+
}
55+
56+
function clickChip(label: string) {
57+
const chip = [...container.querySelectorAll('button')].find(
58+
(node) => node.textContent?.trim() === label
59+
)
60+
if (!chip) throw new Error(`no chip labelled "${label}" (have: ${chipLabels().join(', ')})`)
61+
act(() => {
62+
chip.dispatchEvent(new MouseEvent('click', { bubbles: true }))
63+
})
64+
}
65+
66+
describe('SettingsHeaderShell action routing', () => {
67+
it('renders Delete before Discard and Save even though the array lists it last', () => {
68+
const actions: SettingsAction[] = [
69+
...saveDiscardActions({ dirty: true, saving: false, onSave: vi.fn(), onDiscard: vi.fn() }),
70+
{ id: 'delete', text: 'Delete', onSelect: vi.fn() },
71+
]
72+
73+
renderHeader(actions)
74+
75+
const labels = chipLabels()
76+
expect(labels.indexOf('Delete')).toBeLessThan(labels.indexOf('Discard'))
77+
expect(labels.indexOf('Discard')).toBeLessThan(labels.indexOf('Save'))
78+
})
79+
80+
it('invokes the action that was clicked, not the one at that render position', () => {
81+
const onSave = vi.fn()
82+
const onDiscard = vi.fn()
83+
const onDelete = vi.fn()
84+
85+
renderHeader([
86+
...saveDiscardActions({ dirty: true, saving: false, onSave, onDiscard }),
87+
{ id: 'delete', text: 'Delete', onSelect: onDelete },
88+
])
89+
90+
// Delete renders first but lives at source index 2.
91+
clickChip('Delete')
92+
expect(onDelete).toHaveBeenCalledTimes(1)
93+
expect(onSave).not.toHaveBeenCalled()
94+
expect(onDiscard).not.toHaveBeenCalled()
95+
96+
clickChip('Save')
97+
expect(onSave).toHaveBeenCalledTimes(1)
98+
expect(onDelete).toHaveBeenCalledTimes(1)
99+
})
100+
101+
it('stays correctly bound when a conditional action shifts every index', () => {
102+
// Sandboxes: Discard only exists while dirty, so Delete moves 2 -> 1.
103+
const onSave = vi.fn()
104+
const onDelete = vi.fn()
105+
106+
renderHeader([
107+
...saveDiscardActions({ dirty: false, saving: false, onSave, onDiscard: vi.fn() }),
108+
{ id: 'delete', text: 'Delete', onSelect: onDelete },
109+
])
110+
111+
clickChip('Delete')
112+
113+
expect(onDelete).toHaveBeenCalledTimes(1)
114+
expect(onSave).not.toHaveBeenCalled()
115+
})
116+
})

0 commit comments

Comments
 (0)