Skip to content

Commit 853c395

Browse files
committed
fix(resources): make context menus selection-aware
1 parent 7173a3f commit 853c395

16 files changed

Lines changed: 270 additions & 61 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons'
1515
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options'
1616
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders/move-options'
17+
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
1718

1819
interface FolderContextMenuProps {
1920
isOpen: boolean
@@ -29,6 +30,7 @@ interface FolderContextMenuProps {
2930
pinned: boolean
3031
moveOptions?: MoveOptionNode[]
3132
canEdit: boolean
33+
selectedCount: number
3234
}
3335

3436
/**
@@ -56,8 +58,11 @@ export const FolderContextMenu = memo(function FolderContextMenu({
5658
pinned,
5759
moveOptions,
5860
canEdit,
61+
selectedCount,
5962
}: FolderContextMenuProps) {
63+
const isMultiSelect = selectedCount > 1
6064
const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0)
65+
const hasActionsAboveDestructive = !isMultiSelect || hasMove
6166

6267
return (
6368
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -75,41 +80,47 @@ export const FolderContextMenu = memo(function FolderContextMenu({
7580
sideOffset={4}
7681
onCloseAutoFocus={(e) => e.preventDefault()}
7782
>
78-
<DropdownMenuItem onSelect={onOpen}>
79-
<Eye />
80-
Open
81-
</DropdownMenuItem>
82-
<DropdownMenuItem onSelect={onTogglePin}>
83-
<Pin />
84-
{pinned ? 'Unpin' : 'Pin'}
85-
</DropdownMenuItem>
86-
{onCopyId && (
87-
<DropdownMenuItem onSelect={onCopyId}>
88-
<Duplicate />
89-
Copy ID
90-
</DropdownMenuItem>
83+
{!isMultiSelect && (
84+
<>
85+
<DropdownMenuItem onSelect={onOpen}>
86+
<Eye />
87+
Open
88+
</DropdownMenuItem>
89+
<DropdownMenuItem onSelect={onTogglePin}>
90+
<Pin />
91+
{pinned ? 'Unpin' : 'Pin'}
92+
</DropdownMenuItem>
93+
{onCopyId && (
94+
<DropdownMenuItem onSelect={onCopyId}>
95+
<Duplicate />
96+
Copy ID
97+
</DropdownMenuItem>
98+
)}
99+
</>
91100
)}
92101
{canEdit && (
93102
<>
94-
<DropdownMenuItem onSelect={onRename}>
95-
<Pencil />
96-
Rename
97-
</DropdownMenuItem>
103+
{!isMultiSelect && (
104+
<DropdownMenuItem onSelect={onRename}>
105+
<Pencil />
106+
Rename
107+
</DropdownMenuItem>
108+
)}
98109
{hasMove && (
99110
<DropdownMenuSub>
100111
<DropdownMenuSubTrigger>
101112
<FolderInput />
102-
Move to
113+
{selectionActionLabel('Move', selectedCount, 'Move to')}
103114
</DropdownMenuSubTrigger>
104115
<DropdownMenuSubContent>
105116
{renderMoveOptions(moveOptions!, onMove!)}
106117
</DropdownMenuSubContent>
107118
</DropdownMenuSub>
108119
)}
109-
<DropdownMenuSeparator />
120+
{hasActionsAboveDestructive && <DropdownMenuSeparator />}
110121
<DropdownMenuItem onSelect={onDelete}>
111122
<Trash />
112-
Delete
123+
{selectionActionLabel('Delete', selectedCount)}
113124
</DropdownMenuItem>
114125
</>
115126
)}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { ReactNode } from 'react'
2+
import { renderToStaticMarkup } from 'react-dom/server'
3+
import { describe, expect, it, vi } from 'vitest'
4+
5+
vi.mock('@sim/emcn', () => ({
6+
DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) =>
7+
open ? <>{children}</> : null,
8+
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
9+
DropdownMenuItem: ({ children }: { children: ReactNode }) => <span>{children}</span>,
10+
DropdownMenuSeparator: () => <hr />,
11+
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
12+
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}</>,
13+
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>,
14+
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
15+
Upload: () => null,
16+
}))
17+
18+
vi.mock('@sim/emcn/icons', () => ({
19+
Database: () => null,
20+
Download: () => null,
21+
Duplicate: () => null,
22+
Eye: () => null,
23+
FolderInput: () => null,
24+
Pencil: () => null,
25+
Pin: () => null,
26+
SquareArrowUpRight: () => null,
27+
TagIcon: () => null,
28+
Trash: () => null,
29+
}))
30+
31+
vi.mock('@/app/workspace/[workspaceId]/components/folders', () => ({
32+
renderMoveOptions: () => <span>Destination</span>,
33+
}))
34+
35+
vi.mock('@/app/workspace/[workspaceId]/components/folders/move-options', () => ({
36+
renderMoveOptions: () => <span>Destination</span>,
37+
}))
38+
39+
import { FolderContextMenu } from '@/app/workspace/[workspaceId]/components/folders/folder-context-menu'
40+
import { KnowledgeBaseContextMenu } from '@/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu'
41+
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu'
42+
43+
const POSITION = { x: 0, y: 0 }
44+
const MOVE_OPTIONS = [{ value: '__root__', label: 'Root', children: [] }]
45+
46+
describe('selection-aware resource context menus', () => {
47+
it('limits a multi-table menu to actions that can target the selection', () => {
48+
const menu = renderToStaticMarkup(
49+
<TableContextMenu
50+
isOpen
51+
position={POSITION}
52+
onClose={() => {}}
53+
onCopyId={() => {}}
54+
onTogglePin={() => {}}
55+
onDelete={() => {}}
56+
onViewSchema={() => {}}
57+
onRename={() => {}}
58+
onImportCsv={() => {}}
59+
onExportCsv={() => {}}
60+
onMove={() => {}}
61+
moveOptions={MOVE_OPTIONS}
62+
selectedCount={3}
63+
/>
64+
)
65+
66+
expect(menu).toContain('Move 3 items')
67+
expect(menu).toContain('Delete 3 items')
68+
expect(menu).not.toContain('View Schema')
69+
expect(menu).not.toContain('Rename')
70+
expect(menu).not.toContain('Copy ID')
71+
expect(menu).not.toContain('Pin')
72+
})
73+
74+
it('limits a multi-base menu to actions that can target the selection', () => {
75+
const menu = renderToStaticMarkup(
76+
<KnowledgeBaseContextMenu
77+
isOpen
78+
position={POSITION}
79+
onClose={() => {}}
80+
onOpenInNewTab={() => {}}
81+
onViewTags={() => {}}
82+
onCopyId={() => {}}
83+
onTogglePin={() => {}}
84+
onEdit={() => {}}
85+
onDelete={() => {}}
86+
onMove={() => {}}
87+
moveOptions={MOVE_OPTIONS}
88+
selectedCount={2}
89+
/>
90+
)
91+
92+
expect(menu).toContain('Move 2 items')
93+
expect(menu).toContain('Delete 2 items')
94+
expect(menu).not.toContain('Open in new tab')
95+
expect(menu).not.toContain('View tags')
96+
expect(menu).not.toContain('Copy ID')
97+
expect(menu).not.toContain('Pin')
98+
expect(menu).not.toContain('Edit')
99+
})
100+
101+
it('uses the same group-action contract when a selected folder opens the menu', () => {
102+
const menu = renderToStaticMarkup(
103+
<FolderContextMenu
104+
isOpen
105+
position={POSITION}
106+
onClose={() => {}}
107+
onOpen={() => {}}
108+
onRename={() => {}}
109+
onDelete={() => {}}
110+
onCopyId={() => {}}
111+
onMove={() => {}}
112+
onTogglePin={() => {}}
113+
pinned={false}
114+
moveOptions={MOVE_OPTIONS}
115+
canEdit
116+
selectedCount={4}
117+
/>
118+
)
119+
120+
expect(menu).toContain('Move 4 items')
121+
expect(menu).toContain('Delete 4 items')
122+
expect(menu).not.toContain('Open')
123+
expect(menu).not.toContain('Rename')
124+
expect(menu).not.toContain('Copy ID')
125+
expect(menu).not.toContain('Pin')
126+
})
127+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
selectionActionLabel,
4+
selectionLabel,
5+
} from '@/app/workspace/[workspaceId]/components/resource/selection-label'
6+
7+
describe('selection labels', () => {
8+
it('uses the selected item name for a single-row confirmation', () => {
9+
expect(selectionLabel(1, 'Quarterly data')).toBe('Quarterly data')
10+
})
11+
12+
it('uses the selection count for a multi-row confirmation', () => {
13+
expect(selectionLabel(3, 'Quarterly data')).toBe('3 selected items')
14+
})
15+
16+
it('keeps single-row action labels terse', () => {
17+
expect(selectionActionLabel('Move', 1, 'Move to')).toBe('Move to')
18+
})
19+
20+
it('states the scope of a multi-row action', () => {
21+
expect(selectionActionLabel('Delete', 3)).toBe('Delete 3 items')
22+
})
23+
})

apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,12 @@ export function selectionLabel(count: number, firstName: string | undefined): st
77
if (count === 1) return firstName ?? 'selected item'
88
return `${count} selected items`
99
}
10+
11+
export function selectionActionLabel(
12+
action: string,
13+
selectedCount: number,
14+
singleItemLabel = action
15+
): string {
16+
if (selectedCount <= 1) return singleItemLabel
17+
return `${action} ${selectedCount} items`
18+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { Download, Link, Pin, Send, Trash } from '@sim/emcn/icons'
1919
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
2020
import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders'
21+
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
2122

2223
interface FileRowContextMenuProps {
2324
isOpen: boolean
@@ -98,7 +99,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
9899
{onDownload && (
99100
<DropdownMenuItem onSelect={onDownload}>
100101
<Download />
101-
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
102+
{selectionActionLabel('Download', selectedCount)}
102103
</DropdownMenuItem>
103104
)}
104105
{!isMultiSelect && (
@@ -125,7 +126,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
125126
<DropdownMenuSub>
126127
<DropdownMenuSubTrigger>
127128
<FolderInput />
128-
{isMultiSelect ? `Move ${selectedCount} items` : 'Move to'}
129+
{selectionActionLabel('Move', selectedCount, 'Move to')}
129130
</DropdownMenuSubTrigger>
130131
<DropdownMenuSubContent>
131132
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
@@ -140,7 +141,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
140141
{hasActionsAboveDestructive && <DropdownMenuSeparator />}
141142
<DropdownMenuItem onSelect={onDelete}>
142143
<Trash />
143-
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
144+
{selectionActionLabel('Delete', selectedCount)}
144145
</DropdownMenuItem>
145146
</>
146147
)}

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DropdownMenuTrigger,
99
} from '@sim/emcn'
1010
import { Duplicate, Eye, Pencil, Plus, SquareArrowUpRight, Trash } from '@sim/emcn/icons'
11+
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
1112

1213
interface ChunkContextMenuProps {
1314
isOpen: boolean
@@ -26,7 +27,7 @@ interface ChunkContextMenuProps {
2627
disableAddChunk?: boolean
2728
disableEdit?: boolean
2829
isConnectorDocument?: boolean
29-
selectedCount?: number
30+
selectedCount: number
3031
enabledCount?: number
3132
disabledCount?: number
3233
}
@@ -53,7 +54,7 @@ export function ChunkContextMenu({
5354
disableAddChunk = false,
5455
disableEdit = false,
5556
isConnectorDocument = false,
56-
selectedCount = 1,
57+
selectedCount,
5758
enabledCount = 0,
5859
disabledCount = 0,
5960
}: ChunkContextMenuProps) {
@@ -118,15 +119,15 @@ export function ChunkContextMenu({
118119
{onToggleEnabled && (
119120
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
120121
<Eye />
121-
{getToggleLabel()}
122+
{selectionActionLabel(getToggleLabel(), selectedCount)}
122123
</DropdownMenuItem>
123124
)}
124125

125126
{hasActionsAboveDestructive && hasDestructiveSection && <DropdownMenuSeparator />}
126127
{onDelete && (
127128
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
128129
<Trash />
129-
Delete
130+
{selectionActionLabel('Delete', selectedCount)}
130131
</DropdownMenuItem>
131132
)}
132133
</>

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DropdownMenuTrigger,
99
} from '@sim/emcn'
1010
import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
11+
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
1112

1213
interface DocumentContextMenuProps {
1314
isOpen: boolean
@@ -26,7 +27,7 @@ interface DocumentContextMenuProps {
2627
disableToggleEnabled?: boolean
2728
disableDelete?: boolean
2829
disableAddDocument?: boolean
29-
selectedCount?: number
30+
selectedCount: number
3031
enabledCount?: number
3132
disabledCount?: number
3233
}
@@ -53,7 +54,7 @@ export function DocumentContextMenu({
5354
disableToggleEnabled = false,
5455
disableDelete = false,
5556
disableAddDocument = false,
56-
selectedCount = 1,
57+
selectedCount,
5758
enabledCount = 0,
5859
disabledCount = 0,
5960
}: DocumentContextMenuProps) {
@@ -124,15 +125,15 @@ export function DocumentContextMenu({
124125
{onToggleEnabled && (
125126
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
126127
<Eye />
127-
{getToggleLabel()}
128+
{selectionActionLabel(getToggleLabel(), selectedCount)}
128129
</DropdownMenuItem>
129130
)}
130131

131132
{hasActionsAboveDestructive && hasDestructiveSection && <DropdownMenuSeparator />}
132133
{onDelete && (
133134
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
134135
<Trash />
135-
Delete
136+
{selectionActionLabel('Delete', selectedCount)}
136137
</DropdownMenuItem>
137138
)}
138139
</>

0 commit comments

Comments
 (0)