diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 62d0ec5cacf..35f72a05818 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -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 ( !open && onClose()} modal={false}> @@ -75,42 +81,54 @@ export const FolderContextMenu = memo(function FolderContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - - - Open - - - - {pinned ? 'Unpin' : 'Pin'} - - {onCopyId && ( - - - Copy ID - - )} - {canEdit && ( + {!hasAvailableActions ? ( + No actions available + ) : ( <> - - - Rename - - {hasMove && ( - - - - Move to - - - {renderMoveOptions(moveOptions!, onMove!)} - - + {!isMultiSelect && ( + <> + + + Open + + + + {pinned ? 'Unpin' : 'Pin'} + + {onCopyId && ( + + + Copy ID + + )} + + )} + {canEdit && ( + <> + {!isMultiSelect && ( + + + Rename + + )} + {hasMove && ( + + + + {selectionActionLabel('Move', selectedCount, 'Move to')} + + + {renderMoveOptions(moveOptions!, onMove!)} + + + )} + {hasActionsAboveDestructive && } + + + {selectionActionLabel('Delete', selectedCount)} + + )} - - - - Delete - )} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx new file mode 100644 index 00000000000..5a792131fd5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-aware-context-menus.test.tsx @@ -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 }) => {children}, + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => {children}, + 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: () => Destination, +})) + +vi.mock('@/app/workspace/[workspaceId]/components/folders/move-options', () => ({ + renderMoveOptions: () => Destination, +})) + +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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + hasChunk + selectedCount={3} + enabledCount={3} + onToggleEnabled={() => {}} + onDelete={() => {}} + /> + ) + + expect(menu).toContain('Disable 3 items') + expect(menu).toContain('Delete 3 items') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.test.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.test.ts new file mode 100644 index 00000000000..55ca76109ba --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.test.ts @@ -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') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts index fc812dbee55..b0f88be128b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts @@ -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) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx index fbc651748aa..79ffba59465 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -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 && ( - {isMultiSelect ? `Download ${selectedCount} items` : 'Download'} + {selectionActionLabel('Download', selectedCount)} )} {!isMultiSelect && ( @@ -125,7 +126,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ - {isMultiSelect ? `Move ${selectedCount} items` : 'Move to'} + {selectionActionLabel('Move', selectedCount, 'Move to')} onMove(moveOptions[0].value)}> @@ -140,7 +141,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ {hasActionsAboveDestructive && } - {isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'} + {selectionActionLabel('Delete', selectedCount)} )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx index 8a73d105395..e7488424da5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx @@ -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 && ( - {getToggleLabel()} + {toggleLabel} )} @@ -126,7 +128,7 @@ export function ChunkContextMenu({ {onDelete && ( - Delete + {selectionActionLabel('Delete', selectedCount)} )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 76a061a032d..32a08870988 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx index 3f3cb03f63c..c6d9075d0b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx @@ -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 && ( - {getToggleLabel()} + {toggleLabel} )} @@ -132,7 +137,7 @@ export function DocumentContextMenu({ {onDelete && ( - Delete + {selectionActionLabel('Delete', selectedCount)} )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx index 2f5fdad09e7..83ce9100c4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx @@ -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 )} - {showViewTags && onViewTags && ( + {!isMultiSelect && showViewTags && onViewTags && ( View tags )} - {onCopyId && ( + {!isMultiSelect && onCopyId && ( Copy ID )} - {onTogglePin && ( + {!isMultiSelect && onTogglePin && ( {pinned ? 'Unpin' : 'Pin'} )} - {showEdit && onEdit && ( + {!isMultiSelect && showEdit && onEdit && ( Edit @@ -134,7 +139,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ - Move to + {selectionActionLabel('Move', selectedCount, 'Move to')} {renderMoveOptions(moveOptions!, onMove!)} @@ -146,7 +151,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ {showDelete && onDelete && ( - Delete + {selectionActionLabel('Delete', selectedCount)} )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 8ed1934a1a2..2c8258ad4f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -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} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx index 9bb7fbef27d..b7897ce9b03 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx @@ -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 } @@ -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 ( !open && onClose()} modal={false}> @@ -96,25 +98,25 @@ export function TableContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > - {onViewSchema && ( + {!isMultiSelect && onViewSchema && ( View Schema )} - {onRename && ( + {!isMultiSelect && onRename && ( Rename )} - {onImportCsv && ( + {!isMultiSelect && onImportCsv && ( Import CSV )} - {onExportCsv && ( + {!isMultiSelect && onExportCsv && ( Export CSV @@ -124,20 +126,20 @@ export function TableContextMenu({ - Move to + {selectionActionLabel('Move', selectedCount, 'Move to')} {renderMoveOptions(moveOptions, onMove)} )} - {onTogglePin && ( + {!isMultiSelect && onTogglePin && ( {pinned ? 'Unpin' : 'Pin'} )} - {onCopyId && ( + {!isMultiSelect && onCopyId && ( Copy ID @@ -147,7 +149,7 @@ export function TableContextMenu({ {onDelete && ( - Delete + {selectionActionLabel('Delete', selectedCount)} )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index c6d3c51f10f..4410f1bd017 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -1395,6 +1395,7 @@ export function Tables() { disableDelete={!canEdit} disableRename={!canEdit} disableImport={!canEdit} + selectedCount={selectedRowIds.size} /> {activeTable && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx index 12b7d7c8902..2b73e2dc4a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx @@ -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', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 3d4cfef1a2e..447b69008e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -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 + {selectionActionLabel('Duplicate', selectedCount)} )} {showExport && onExport && ( @@ -357,7 +360,7 @@ export function ContextMenu({ }} > - Export + {selectionActionLabel('Export', selectedCount)} )} {openInNewTabPosition === 'last' && showOpenInNewTab && onOpenInNewTab && ( @@ -394,7 +397,7 @@ export function ContextMenu({ }} > - Delete + {selectionActionLabel('Delete', selectedCount)} )} {showCloseTab && onCloseTab && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 0cc55efc27c..4877ba28400 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -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} /> { 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} />