fix(resources): make context menus selection-aware (#6980)

* fix(resources): make context menus selection-aware

* fix(resources): correct bulk action scopes

* fix(knowledge): avoid overstating select-all scope

* fix(knowledge): keep select-all actions consistent
This commit is contained in:
Waleed
2026-08-22 16:04:10 -07:00
committed by GitHub
parent e46fc1d1f5
commit ab35ff4c8c
17 changed files with 476 additions and 100 deletions
@@ -14,6 +14,7 @@ import {
import { Duplicate, Eye, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders/move-options'
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders/move-options'
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface FolderContextMenuProps {
isOpen: boolean
@@ -29,6 +30,7 @@ interface FolderContextMenuProps {
pinned: boolean
moveOptions?: MoveOptionNode[]
canEdit: boolean
selectedCount: number
}
/**
@@ -56,8 +58,12 @@ export const FolderContextMenu = memo(function FolderContextMenu({
pinned,
moveOptions,
canEdit,
selectedCount,
}: FolderContextMenuProps) {
const isMultiSelect = selectedCount > 1
const hasMove = Boolean(onMove && moveOptions && moveOptions.length > 0)
const hasActionsAboveDestructive = !isMultiSelect || hasMove
const hasAvailableActions = !isMultiSelect || canEdit
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -75,42 +81,54 @@ export const FolderContextMenu = memo(function FolderContextMenu({
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={onTogglePin}>
<Pin />
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
{onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Duplicate />
Copy ID
</DropdownMenuItem>
)}
{canEdit && (
{!hasAvailableActions ? (
<DropdownMenuItem disabled>No actions available</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
{hasMove && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
Move to
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{renderMoveOptions(moveOptions!, onMove!)}
</DropdownMenuSubContent>
</DropdownMenuSub>
{!isMultiSelect && (
<>
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={onTogglePin}>
<Pin />
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
{onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Duplicate />
Copy ID
</DropdownMenuItem>
)}
</>
)}
{canEdit && (
<>
{!isMultiSelect && (
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{hasMove && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
{selectionActionLabel('Move', selectedCount, 'Move to')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{renderMoveOptions(moveOptions!, onMove!)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{hasActionsAboveDestructive && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onDelete}>
<Trash />
{selectionActionLabel('Delete', selectedCount)}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
@@ -0,0 +1,207 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@sim/emcn', () => ({
DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) =>
open ? <>{children}</> : null,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <span>{children}</span>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
Upload: () => null,
}))
vi.mock('@sim/emcn/icons', () => ({
Database: () => null,
Download: () => null,
Duplicate: () => null,
Eye: () => null,
FolderInput: () => null,
Pencil: () => null,
Pin: () => null,
Plus: () => null,
SquareArrowUpRight: () => null,
TagIcon: () => null,
Trash: () => null,
}))
vi.mock('@/app/workspace/[workspaceId]/components/folders', () => ({
renderMoveOptions: () => <span>Destination</span>,
}))
vi.mock('@/app/workspace/[workspaceId]/components/folders/move-options', () => ({
renderMoveOptions: () => <span>Destination</span>,
}))
import { FolderContextMenu } from '@/app/workspace/[workspaceId]/components/folders/folder-context-menu'
import { ChunkContextMenu } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu'
import { DocumentContextMenu } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu'
import { KnowledgeBaseContextMenu } from '@/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu'
const POSITION = { x: 0, y: 0 }
const MOVE_OPTIONS = [{ value: '__root__', label: 'Root', children: [] }]
describe('selection-aware resource context menus', () => {
it('limits a multi-table menu to actions that can target the selection', () => {
const menu = renderToStaticMarkup(
<TableContextMenu
isOpen
position={POSITION}
onClose={() => {}}
onCopyId={() => {}}
onTogglePin={() => {}}
onDelete={() => {}}
onViewSchema={() => {}}
onRename={() => {}}
onImportCsv={() => {}}
onExportCsv={() => {}}
onMove={() => {}}
moveOptions={MOVE_OPTIONS}
selectedCount={3}
/>
)
expect(menu).toContain('Move 3 items')
expect(menu).toContain('Delete 3 items')
expect(menu).not.toContain('View Schema')
expect(menu).not.toContain('Rename')
expect(menu).not.toContain('Copy ID')
expect(menu).not.toContain('Pin')
})
it('limits a multi-base menu to actions that can target the selection', () => {
const menu = renderToStaticMarkup(
<KnowledgeBaseContextMenu
isOpen
position={POSITION}
onClose={() => {}}
onOpenInNewTab={() => {}}
onViewTags={() => {}}
onCopyId={() => {}}
onTogglePin={() => {}}
onEdit={() => {}}
onDelete={() => {}}
onMove={() => {}}
moveOptions={MOVE_OPTIONS}
selectedCount={2}
/>
)
expect(menu).toContain('Move 2 items')
expect(menu).toContain('Delete 2 items')
expect(menu).not.toContain('Open in new tab')
expect(menu).not.toContain('View tags')
expect(menu).not.toContain('Copy ID')
expect(menu).not.toContain('Pin')
expect(menu).not.toContain('Edit')
})
it('uses the same group-action contract when a selected folder opens the menu', () => {
const menu = renderToStaticMarkup(
<FolderContextMenu
isOpen
position={POSITION}
onClose={() => {}}
onOpen={() => {}}
onRename={() => {}}
onDelete={() => {}}
onCopyId={() => {}}
onMove={() => {}}
onTogglePin={() => {}}
pinned={false}
moveOptions={MOVE_OPTIONS}
canEdit
selectedCount={4}
/>
)
expect(menu).toContain('Move 4 items')
expect(menu).toContain('Delete 4 items')
expect(menu).not.toContain('Open')
expect(menu).not.toContain('Rename')
expect(menu).not.toContain('Copy ID')
expect(menu).not.toContain('Pin')
})
it('explains when a read-only multi-folder selection has no actions', () => {
const menu = renderToStaticMarkup(
<FolderContextMenu
isOpen
position={POSITION}
onClose={() => {}}
onOpen={() => {}}
onRename={() => {}}
onDelete={() => {}}
onTogglePin={() => {}}
pinned={false}
canEdit={false}
selectedCount={2}
/>
)
expect(menu).toContain('No actions available')
expect(menu).not.toContain('Open')
expect(menu).not.toContain('Delete')
})
it('counts only the documents affected by a mixed-selection toggle', () => {
const menu = renderToStaticMarkup(
<DocumentContextMenu
isOpen
position={POSITION}
onClose={() => {}}
hasDocument
selectedCount={25}
enabledCount={7}
disabledCount={18}
onToggleEnabled={() => {}}
onDelete={() => {}}
/>
)
expect(menu).toContain('Enable 18 items')
expect(menu).toContain('Delete 25 items')
})
it('does not overstate an unknown select-all toggle count', () => {
const menu = renderToStaticMarkup(
<DocumentContextMenu
isOpen
position={POSITION}
onClose={() => {}}
hasDocument
selectedCount={25}
enabledCount={25}
disabledCount={25}
hasExactToggleCount={false}
onToggleEnabled={() => {}}
/>
)
expect(menu).toContain('Enable selected items')
expect(menu).not.toContain('Enable 25 items')
})
it('counts only the chunks affected by a multi-selection toggle', () => {
const menu = renderToStaticMarkup(
<ChunkContextMenu
isOpen
position={POSITION}
onClose={() => {}}
hasChunk
selectedCount={3}
enabledCount={3}
onToggleEnabled={() => {}}
onDelete={() => {}}
/>
)
expect(menu).toContain('Disable 3 items')
expect(menu).toContain('Delete 3 items')
})
})
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
selectionActionLabel,
selectionLabel,
selectionToggleActionLabel,
} from '@/app/workspace/[workspaceId]/components/resource/selection-label'
describe('selection labels', () => {
it('uses the selected item name for a single-row confirmation', () => {
expect(selectionLabel(1, 'Quarterly data')).toBe('Quarterly data')
})
it('uses the selection count for a multi-row confirmation', () => {
expect(selectionLabel(3, 'Quarterly data')).toBe('3 selected items')
})
it('keeps single-row action labels terse', () => {
expect(selectionActionLabel('Move', 1, 'Move to')).toBe('Move to')
})
it('states the scope of a multi-row action', () => {
expect(selectionActionLabel('Delete', 3)).toBe('Delete 3 items')
})
it('counts only disabled items for a mixed-selection enable action', () => {
expect(
selectionToggleActionLabel({
selectedCount: 5,
enabledCount: 2,
disabledCount: 3,
isSelectedItemEnabled: true,
})
).toBe('Enable 3 items')
})
it('keeps a singular affected count visible within a larger selection', () => {
expect(
selectionToggleActionLabel({
selectedCount: 5,
enabledCount: 4,
disabledCount: 1,
isSelectedItemEnabled: true,
})
).toBe('Enable 1 item')
})
it('counts enabled items when a selection can only be disabled', () => {
expect(
selectionToggleActionLabel({
selectedCount: 4,
enabledCount: 4,
disabledCount: 0,
isSelectedItemEnabled: true,
})
).toBe('Disable 4 items')
})
it('keeps the action selection-aware when the affected subset count is unknown', () => {
expect(
selectionToggleActionLabel({
selectedCount: 10,
enabledCount: 10,
disabledCount: 10,
isSelectedItemEnabled: true,
hasExactAffectedCount: false,
})
).toBe('Enable selected items')
})
})
@@ -7,3 +7,37 @@ export function selectionLabel(count: number, firstName: string | undefined): st
if (count === 1) return firstName ?? 'selected item'
return `${count} selected items`
}
export function selectionActionLabel(
action: string,
selectedCount: number,
singleItemLabel = action
): string {
if (selectedCount <= 1) return singleItemLabel
return countedSelectionActionLabel(action, selectedCount)
}
function countedSelectionActionLabel(action: string, count: number): string {
return `${action} ${count} ${count === 1 ? 'item' : 'items'}`
}
interface SelectionToggleActionLabelOptions {
selectedCount: number
enabledCount: number
disabledCount: number
isSelectedItemEnabled: boolean
hasExactAffectedCount?: boolean
}
export function selectionToggleActionLabel({
selectedCount,
enabledCount,
disabledCount,
isSelectedItemEnabled,
hasExactAffectedCount = true,
}: SelectionToggleActionLabelOptions): string {
if (selectedCount <= 1) return isSelectedItemEnabled ? 'Disable' : 'Enable'
const action = disabledCount > 0 ? 'Enable' : 'Disable'
if (!hasExactAffectedCount) return `${action} selected items`
return countedSelectionActionLabel(action, disabledCount > 0 ? disabledCount : enabledCount)
}
@@ -18,6 +18,7 @@ import {
import { Download, Link, Pin, Send, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders'
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface FileRowContextMenuProps {
isOpen: boolean
@@ -98,7 +99,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
{onDownload && (
<DropdownMenuItem onSelect={onDownload}>
<Download />
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
{selectionActionLabel('Download', selectedCount)}
</DropdownMenuItem>
)}
{!isMultiSelect && (
@@ -125,7 +126,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
{isMultiSelect ? `Move ${selectedCount} items` : 'Move to'}
{selectionActionLabel('Move', selectedCount, 'Move to')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
@@ -140,7 +141,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
{hasActionsAboveDestructive && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onDelete}>
<Trash />
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
{selectionActionLabel('Delete', selectedCount)}
</DropdownMenuItem>
</>
)}
@@ -8,6 +8,10 @@ import {
DropdownMenuTrigger,
} from '@sim/emcn'
import { Duplicate, Eye, Pencil, Plus, SquareArrowUpRight, Trash } from '@sim/emcn/icons'
import {
selectionActionLabel,
selectionToggleActionLabel,
} from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface ChunkContextMenuProps {
isOpen: boolean
@@ -26,7 +30,7 @@ interface ChunkContextMenuProps {
disableAddChunk?: boolean
disableEdit?: boolean
isConnectorDocument?: boolean
selectedCount?: number
selectedCount: number
enabledCount?: number
disabledCount?: number
}
@@ -53,19 +57,17 @@ export function ChunkContextMenu({
disableAddChunk = false,
disableEdit = false,
isConnectorDocument = false,
selectedCount = 1,
selectedCount,
enabledCount = 0,
disabledCount = 0,
}: ChunkContextMenuProps) {
const isMultiSelect = selectedCount > 1
const getToggleLabel = () => {
if (isMultiSelect) {
if (disabledCount > 0) return 'Enable'
return 'Disable'
}
return isChunkEnabled ? 'Disable' : 'Enable'
}
const toggleLabel = selectionToggleActionLabel({
selectedCount,
enabledCount,
disabledCount,
isSelectedItemEnabled: isChunkEnabled,
})
const hasNavigationSection = !isMultiSelect && !!onOpenInNewTab
const hasEditSection = !isMultiSelect && (!!onEdit || !!onCopyContent)
@@ -118,7 +120,7 @@ export function ChunkContextMenu({
{onToggleEnabled && (
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
<Eye />
{getToggleLabel()}
{toggleLabel}
</DropdownMenuItem>
)}
@@ -126,7 +128,7 @@ export function ChunkContextMenu({
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
{selectionActionLabel('Delete', selectedCount)}
</DropdownMenuItem>
)}
</>
@@ -687,6 +687,7 @@ export function KnowledgeBase({
* Handles selecting/deselecting a document
*/
const handleSelectDocument = (docId: string, checked: boolean) => {
setIsSelectAllMode(false)
setSelectedDocuments((prev) => {
const newSet = new Set(prev)
if (checked) {
@@ -908,6 +909,7 @@ export function KnowledgeBase({
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => !doc.enabled).length
const selectedDocumentCount = isSelectAllMode ? pagination.total : selectedDocuments.size
const handleDocumentContextMenu = useCallback(
(e: React.MouseEvent, docId: string) => {
@@ -917,6 +919,7 @@ export function KnowledgeBase({
const isCurrentlySelected = selectedDocuments.has(doc.id)
if (!isCurrentlySelected) {
setIsSelectAllMode(false)
setSelectedDocuments(new Set([doc.id]))
}
@@ -1422,15 +1425,15 @@ export function KnowledgeBase({
srTitle='Delete Documents'
title='Delete Documents'
text={[
`Are you sure you want to delete ${selectedDocuments.size} document${selectedDocuments.size === 1 ? '' : 's'}? `,
`Are you sure you want to delete ${selectedDocumentCount} document${selectedDocumentCount === 1 ? '' : 's'}? `,
{
text: `This will permanently delete the selected document${selectedDocuments.size === 1 ? '' : 's'}.`,
text: `This will permanently delete the selected document${selectedDocumentCount === 1 ? '' : 's'}.`,
error: true,
},
' This action cannot be undone.',
]}
confirm={{
label: `Delete ${selectedDocuments.size} Document${selectedDocuments.size === 1 ? '' : 's'}`,
label: `Delete ${selectedDocumentCount} Document${selectedDocumentCount === 1 ? '' : 's'}`,
onClick: confirmBulkDelete,
pending: isBulkOperating,
pendingLabel: 'Deleting...',
@@ -1501,11 +1504,12 @@ export function KnowledgeBase({
onClose={handleContextMenuClose}
hasDocument={contextMenuDocument !== null}
isDocumentEnabled={contextMenuDocument?.enabled ?? true}
selectedCount={selectedDocuments.size}
selectedCount={selectedDocumentCount}
enabledCount={enabledCount}
disabledCount={disabledCount}
hasExactToggleCount={!isSelectAllMode || enabledFilter !== 'all'}
onOpenInNewTab={
contextMenuDocument && selectedDocuments.size === 1
contextMenuDocument && selectedDocumentCount === 1
? () => {
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
@@ -1519,14 +1523,14 @@ export function KnowledgeBase({
: undefined
}
onOpenSource={
contextMenuDocument?.sourceUrl && selectedDocuments.size === 1
contextMenuDocument?.sourceUrl && selectedDocumentCount === 1
? () => window.open(contextMenuDocument.sourceUrl!, '_blank', 'noopener,noreferrer')
: undefined
}
onRename={contextMenuDocument ? () => handleRenameDocument(contextMenuDocument) : undefined}
onToggleEnabled={
contextMenuDocument
? selectedDocuments.size > 1
? selectedDocumentCount > 1
? () => {
if (disabledCount > 0) {
handleBulkEnable()
@@ -1538,13 +1542,13 @@ export function KnowledgeBase({
: undefined
}
onViewTags={
contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit
contextMenuDocument && selectedDocumentCount === 1 && userPermissions.canEdit
? () => handleViewDocumentTags(contextMenuDocument)
: undefined
}
onDelete={
contextMenuDocument
? selectedDocuments.size > 1
? selectedDocumentCount > 1
? handleBulkDelete
: () => handleDeleteDocument(contextMenuDocument.id)
: undefined
@@ -8,6 +8,10 @@ import {
DropdownMenuTrigger,
} from '@sim/emcn'
import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
import {
selectionActionLabel,
selectionToggleActionLabel,
} from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface DocumentContextMenuProps {
isOpen: boolean
@@ -26,9 +30,10 @@ interface DocumentContextMenuProps {
disableToggleEnabled?: boolean
disableDelete?: boolean
disableAddDocument?: boolean
selectedCount?: number
selectedCount: number
enabledCount?: number
disabledCount?: number
hasExactToggleCount?: boolean
}
/**
@@ -53,19 +58,19 @@ export function DocumentContextMenu({
disableToggleEnabled = false,
disableDelete = false,
disableAddDocument = false,
selectedCount = 1,
selectedCount,
enabledCount = 0,
disabledCount = 0,
hasExactToggleCount = true,
}: DocumentContextMenuProps) {
const isMultiSelect = selectedCount > 1
const getToggleLabel = () => {
if (isMultiSelect) {
if (disabledCount > 0) return 'Enable'
return 'Disable'
}
return isDocumentEnabled ? 'Disable' : 'Enable'
}
const toggleLabel = selectionToggleActionLabel({
selectedCount,
enabledCount,
disabledCount,
isSelectedItemEnabled: isDocumentEnabled,
hasExactAffectedCount: hasExactToggleCount,
})
const hasNavigationSection = !isMultiSelect && (!!onOpenInNewTab || !!onOpenSource)
const hasEditSection = !isMultiSelect && (!!onRename || !!onViewTags)
@@ -124,7 +129,7 @@ export function DocumentContextMenu({
{onToggleEnabled && (
<DropdownMenuItem disabled={disableToggleEnabled} onSelect={onToggleEnabled}>
<Eye />
{getToggleLabel()}
{toggleLabel}
</DropdownMenuItem>
)}
@@ -132,7 +137,7 @@ export function DocumentContextMenu({
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
{selectionActionLabel('Delete', selectedCount)}
</DropdownMenuItem>
)}
</>
@@ -22,6 +22,7 @@ import {
} from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders'
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface KnowledgeBaseContextMenuProps {
isOpen: boolean
@@ -44,6 +45,7 @@ interface KnowledgeBaseContextMenuProps {
showDelete?: boolean
disableEdit?: boolean
disableDelete?: boolean
selectedCount: number
}
/**
@@ -69,11 +71,14 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
showDelete = true,
disableEdit = false,
disableDelete = false,
selectedCount,
}: KnowledgeBaseContextMenuProps) {
const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin
const isMultiSelect = selectedCount > 1
const hasNavigationSection = !isMultiSelect && showOpenInNewTab && !!onOpenInNewTab
const hasInfoSection =
!isMultiSelect && ((showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin)
const hasMoveSection = !disableEdit && !!onMove && !!moveOptions && moveOptions.length > 0
const hasEditSection = (showEdit && !!onEdit) || hasMoveSection
const hasEditSection = (!isMultiSelect && showEdit && !!onEdit) || hasMoveSection
const hasDestructiveSection = showDelete && !!onDelete
const hasActionsAboveDestructive = hasNavigationSection || hasInfoSection || hasEditSection
@@ -105,25 +110,25 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
Open in new tab
</DropdownMenuItem>
)}
{showViewTags && onViewTags && (
{!isMultiSelect && showViewTags && onViewTags && (
<DropdownMenuItem onSelect={onViewTags}>
<TagIcon />
View tags
</DropdownMenuItem>
)}
{onCopyId && (
{!isMultiSelect && onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Duplicate />
Copy ID
</DropdownMenuItem>
)}
{onTogglePin && (
{!isMultiSelect && onTogglePin && (
<DropdownMenuItem onSelect={onTogglePin}>
<Pin />
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
)}
{showEdit && onEdit && (
{!isMultiSelect && showEdit && onEdit && (
<DropdownMenuItem disabled={disableEdit} onSelect={onEdit}>
<Pencil />
Edit
@@ -134,7 +139,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
Move to
{selectionActionLabel('Move', selectedCount, 'Move to')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{renderMoveOptions(moveOptions!, onMove!)}
@@ -146,7 +151,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
{showDelete && onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
{selectionActionLabel('Delete', selectedCount)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
@@ -1462,6 +1462,7 @@ export function Knowledge() {
showDelete
disableEdit={!canEdit}
disableDelete={!canEdit}
selectedCount={selectedRowIds.size}
/>
)}
@@ -1479,6 +1480,7 @@ export function Knowledge() {
onMove={handleMoveFolderFromMenu}
moveOptions={activeFolderMoveOptions}
canEdit={canEdit}
selectedCount={selectedRowIds.size}
/>
)}
@@ -14,6 +14,7 @@ import {
import { Database, Download, Duplicate, FolderInput, Pencil, Pin, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders'
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface TableContextMenuProps {
isOpen: boolean
@@ -35,6 +36,7 @@ interface TableContextMenuProps {
disableRename?: boolean
disableImport?: boolean
disableExport?: boolean
selectedCount: number
menuRef?: React.RefObject<HTMLDivElement | null>
}
@@ -56,7 +58,11 @@ export function TableContextMenu({
disableRename = false,
disableImport = false,
disableExport = false,
selectedCount,
}: TableContextMenuProps) {
const isMultiSelect = selectedCount > 1
const hasMoveAction = !!(onMove && moveOptions && moveOptions.length > 0)
/**
* `Move to` needs a NON-EMPTY `moveOptions`, not just the handler — the looser
* `onMove` alone draws the rule with nothing above it for a table whose other
@@ -66,13 +72,9 @@ export function TableContextMenu({
* group, with both sides built from the items' exact render conditions.
*/
const hasActionsAboveDestructive =
onViewSchema ||
onRename ||
onImportCsv ||
onExportCsv ||
(onMove && moveOptions && moveOptions.length > 0) ||
onCopyId ||
onTogglePin
hasMoveAction ||
(!isMultiSelect &&
!!(onViewSchema || onRename || onImportCsv || onExportCsv || onCopyId || onTogglePin))
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -96,25 +98,25 @@ export function TableContextMenu({
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onViewSchema && (
{!isMultiSelect && onViewSchema && (
<DropdownMenuItem onSelect={onViewSchema}>
<Database />
View Schema
</DropdownMenuItem>
)}
{onRename && (
{!isMultiSelect && onRename && (
<DropdownMenuItem disabled={disableRename} onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{onImportCsv && (
{!isMultiSelect && onImportCsv && (
<DropdownMenuItem disabled={disableImport} onSelect={onImportCsv}>
<Upload />
Import CSV
</DropdownMenuItem>
)}
{onExportCsv && (
{!isMultiSelect && onExportCsv && (
<DropdownMenuItem disabled={disableExport} onSelect={onExportCsv}>
<Download />
Export CSV
@@ -124,20 +126,20 @@ export function TableContextMenu({
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
Move to
{selectionActionLabel('Move', selectedCount, 'Move to')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{renderMoveOptions(moveOptions, onMove)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{onTogglePin && (
{!isMultiSelect && onTogglePin && (
<DropdownMenuItem onSelect={onTogglePin}>
<Pin />
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
)}
{onCopyId && (
{!isMultiSelect && onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Duplicate />
Copy ID
@@ -147,7 +149,7 @@ export function TableContextMenu({
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
{selectionActionLabel('Delete', selectedCount)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
@@ -1395,6 +1395,7 @@ export function Tables() {
disableDelete={!canEdit}
disableRename={!canEdit}
disableImport={!canEdit}
selectedCount={selectedRowIds.size}
/>
<FolderContextMenu
@@ -1417,6 +1418,7 @@ export function Tables() {
onMove={canEdit ? handleMoveFolderFromMenu : undefined}
moveOptions={canEdit ? activeFolderMoveOptions : undefined}
canEdit={canEdit}
selectedCount={selectedRowIds.size}
/>
{activeTable && (
@@ -35,7 +35,7 @@ function mountSurroundingMenu() {
surroundingMenuItem = item
}
function renderMenu(onClose: () => void, onDelete: () => void = () => {}) {
function renderMenu(onClose: () => void, onDelete: () => void = () => {}, selectedCount = 1) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
mountSurroundingMenu()
plainButton = document.createElement('button')
@@ -53,6 +53,7 @@ function renderMenu(onClose: () => void, onDelete: () => void = () => {}) {
onDelete={onDelete}
showRename={false}
showDuplicate={false}
selectedCount={selectedCount}
/>
)
)
@@ -136,6 +137,16 @@ describe('sidebar context menu dismissal', () => {
expect(onClose).toHaveBeenCalled()
})
it('states when delete applies to a multi-item selection', () => {
renderMenu(vi.fn(), vi.fn(), 3)
expect(
Array.from(document.querySelectorAll('[role="menuitem"]')).some(
(item) => item.textContent === 'Delete 3 items'
)
).toBe(true)
})
})
describe('separators', () => {
@@ -26,6 +26,7 @@ import {
Unlock,
X,
} from '@sim/emcn/icons'
import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label'
interface ContextMenuProps {
isOpen: boolean
@@ -96,6 +97,7 @@ interface ContextMenuProps {
onUploadLogo?: () => void
showUploadLogo?: boolean
disableUploadLogo?: boolean
selectedCount?: number
}
/**
@@ -164,6 +166,7 @@ export function ContextMenu({
onUploadLogo,
showUploadLogo = false,
disableUploadLogo = false,
selectedCount = 1,
}: ContextMenuProps) {
const hasActionsAboveDestructive =
(showOpenInNewTab && onOpenInNewTab) ||
@@ -345,7 +348,7 @@ export function ContextMenu({
}}
>
<Duplicate />
Duplicate
{selectionActionLabel('Duplicate', selectedCount)}
</DropdownMenuItem>
)}
{showExport && onExport && (
@@ -357,7 +360,7 @@ export function ContextMenu({
}}
>
<Download />
Export
{selectionActionLabel('Export', selectedCount)}
</DropdownMenuItem>
)}
{openInNewTabPosition === 'last' && showOpenInNewTab && onOpenInNewTab && (
@@ -394,7 +397,7 @@ export function ContextMenu({
}}
>
<Trash />
Delete
{selectionActionLabel('Delete', selectedCount)}
</DropdownMenuItem>
)}
{showCloseTab && onCloseTab && (
@@ -475,6 +475,10 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
const isMixedSelection = useMemo(() => {
return capturedSelectionRef.current?.isMixed ?? false
}, [isContextMenuOpen])
const contextMenuSelectedCount = capturedSelectionRef.current
? capturedSelectionRef.current.workflowIds.length +
capturedSelectionRef.current.folderIds.length
: 1
const hasExportableContent = useMemo(() => {
if (!capturedSelectionRef.current) return hasWorkflows
@@ -583,8 +587,8 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
onDuplicate={handleDuplicate}
onExport={handleExport}
onDelete={handleOpenDeleteModal}
showCreate={!isMixedSelection}
showCreateFolder={!isMixedSelection}
showCreate={!isMixedSelection && selectedFolders.size <= 1}
showCreateFolder={!isMixedSelection && selectedFolders.size <= 1}
showRename={!isMixedSelection && selectedFolders.size <= 1}
showDuplicate={true}
showExport={true}
@@ -605,6 +609,7 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
showLock={!isMixedSelection && selectedFolders.size <= 1}
disableLock={!userPermissions.canAdmin || inheritedFolderLocked}
isLocked={effectiveLocked}
selectedCount={contextMenuSelectedCount}
/>
<DeleteModal
@@ -200,6 +200,10 @@ export const WorkflowItem = memo(function WorkflowItem({
const isMixedSelection = useMemo(() => {
return capturedSelectionRef.current?.isMixed ?? false
}, [isContextMenuOpen])
const contextMenuSelectedCount = capturedSelectionRef.current
? capturedSelectionRef.current.workflowIds.length +
capturedSelectionRef.current.folderIds.length
: 1
const captureSelectionState = useCallback(() => {
const store = useFolderStore.getState()
@@ -503,6 +507,7 @@ export const WorkflowItem = memo(function WorkflowItem({
showLock={!isMixedSelection && selectedWorkflows.size <= 1}
disableLock={!userPermissions.canAdmin || inheritedFolderLocked}
isLocked={effectiveLocked}
selectedCount={contextMenuSelectedCount}
/>
<DeleteModal
@@ -1858,6 +1858,7 @@ export const Sidebar = memo(function Sidebar({
showDuplicate={false}
disableRename={!canEdit}
disableDelete={!canEdit}
selectedCount={contextMenuSelectionRef.current.chatIds.length}
/>
<DeleteModal