Skip to content

Commit 130db81

Browse files
committed
improvement(tables): disable the default view's delete action with a tooltip
1 parent 3b4d9e9 commit 130db81

2 files changed

Lines changed: 68 additions & 29 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe('ViewsMenu', () => {
7070
document.body.appendChild(container)
7171
const root = createRoot(container)
7272
const onSetDefault = vi.fn()
73+
const onDelete = vi.fn()
7374

7475
act(() => {
7576
root.render(
@@ -79,15 +80,28 @@ describe('ViewsMenu', () => {
7980
onSelect={vi.fn()}
8081
onRename={vi.fn()}
8182
onSetDefault={onSetDefault}
82-
onDelete={vi.fn()}
83+
onDelete={onDelete}
8384
onNewView={vi.fn()}
8485
canEdit
8586
/>
8687
)
8788
})
8889
act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Views"]')?.click())
8990

90-
expect(document.body.querySelectorAll('button[aria-label="Delete"]')).toHaveLength(1)
91+
// The default view's Delete stays hoverable (aria-disabled, no native
92+
// title) so its tooltip can explain why it is inert.
93+
const deleteButtons = [
94+
...document.body.querySelectorAll<HTMLButtonElement>('button[aria-label="Delete"]'),
95+
]
96+
expect(deleteButtons).toHaveLength(2)
97+
const defaultDelete = deleteButtons.find(
98+
(button) => button.getAttribute('aria-disabled') === 'true'
99+
)
100+
expect(defaultDelete).not.toBeUndefined()
101+
expect(defaultDelete?.title).toBe('')
102+
act(() => defaultDelete?.click())
103+
expect(onDelete).not.toHaveBeenCalled()
104+
91105
const defaultPin = document.body.querySelector<HTMLButtonElement>(
92106
'button[aria-label="Current default view"]'
93107
)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
PopoverContent,
1414
PopoverItem,
1515
PopoverSection,
16+
Tooltip,
1617
} from '@sim/emcn'
1718
import { Check, Pencil, Pin, Plus, Trash } from '@sim/emcn/icons'
1819
import type { TableViewWire } from '@/lib/api/contracts/tables'
@@ -169,15 +170,14 @@ export const ViewsMenu = memo(function ViewsMenu({
169170
label: 'Rename',
170171
onClick: () => runAndClose(() => onRename(view.id)),
171172
},
172-
...(!view.isDefault
173-
? [
174-
{
175-
icon: Trash,
176-
label: 'Delete',
177-
onClick: () => runAndClose(() => onDelete(view.id)),
178-
},
179-
]
180-
: []),
173+
{
174+
icon: Trash,
175+
label: 'Delete',
176+
disabledReason: view.isDefault
177+
? 'Default view cannot be deleted'
178+
: undefined,
179+
onClick: () => runAndClose(() => onDelete(view.id)),
180+
},
181181
]
182182
: undefined
183183
}
@@ -207,6 +207,8 @@ interface ViewRowAction {
207207
icon: React.ElementType
208208
label: string
209209
onClick: () => void
210+
/** Renders the action inert and dimmed, with this text in its hover tooltip. */
211+
disabledReason?: string
210212
}
211213

212214
interface ViewRowDefaultState {
@@ -252,24 +254,47 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr
252254
</PopoverItem>
253255
{actionCount > 0 && (
254256
<div className='pointer-events-none absolute right-1.5 flex items-center gap-0.5'>
255-
{actions?.map((action) => (
256-
<Button
257-
key={action.label}
258-
type='button'
259-
variant='quiet'
260-
size='icon'
261-
aria-label={action.label}
262-
title={action.label}
263-
onClick={(event) => {
264-
event.preventDefault()
265-
event.stopPropagation()
266-
action.onClick()
267-
}}
268-
className='pointer-events-none opacity-0 transition-[background-color,color,opacity] group-focus-within/view:pointer-events-auto group-focus-within/view:opacity-100 group-hover/view:pointer-events-auto group-hover/view:opacity-100'
269-
>
270-
<action.icon className='size-3' />
271-
</Button>
272-
))}
257+
{actions?.map((action) => {
258+
// Disabled via aria-disabled, not the `disabled` attribute: the button
259+
// must keep receiving hover and focus events so the tooltip can explain
260+
// why it is inert, and Button's disabled:opacity-70 would otherwise
261+
// leak it through the hidden (opacity-0) resting state.
262+
const button = (
263+
<Button
264+
key={action.label}
265+
type='button'
266+
variant='quiet'
267+
size='icon'
268+
aria-label={action.label}
269+
title={action.disabledReason ? undefined : action.label}
270+
aria-disabled={action.disabledReason ? true : undefined}
271+
onClick={(event) => {
272+
event.preventDefault()
273+
event.stopPropagation()
274+
if (action.disabledReason) return
275+
action.onClick()
276+
}}
277+
className={cn(
278+
'pointer-events-none opacity-0 transition-[background-color,color,opacity] group-focus-within/view:pointer-events-auto group-hover/view:pointer-events-auto',
279+
action.disabledReason
280+
? 'cursor-default hover-hover:bg-transparent group-focus-within/view:opacity-40 group-hover/view:opacity-40'
281+
: 'group-focus-within/view:opacity-100 group-hover/view:opacity-100'
282+
)}
283+
>
284+
<action.icon className='size-3' />
285+
</Button>
286+
)
287+
return action.disabledReason ? (
288+
<Tooltip.Root key={action.label}>
289+
<Tooltip.Trigger asChild>{button}</Tooltip.Trigger>
290+
<Tooltip.Content>
291+
<p>{action.disabledReason}</p>
292+
</Tooltip.Content>
293+
</Tooltip.Root>
294+
) : (
295+
button
296+
)
297+
})}
273298
{defaultState && (
274299
<Button
275300
type='button'

0 commit comments

Comments
 (0)