Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +30,7 @@ interface FolderContextMenuProps {
pinned: boolean
moveOptions?: MoveOptionNode[]
canEdit: boolean
selectedCount: number
}

/**
Expand Down Expand Up @@ -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}>
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading