diff --git a/shared/src/api/generated/folders.ts b/shared/src/api/generated/folders.ts index 59e1bd7380..0d4143f1c7 100644 --- a/shared/src/api/generated/folders.ts +++ b/shared/src/api/generated/folders.ts @@ -231,6 +231,7 @@ export type FolderListItem = { status: string attrib?: object ownAttrib?: string[] + hasVersions: boolean createdAt: string updatedAt: string } diff --git a/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx b/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx index 217442a68f..54801a3948 100644 --- a/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx +++ b/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx @@ -1321,6 +1321,7 @@ const TableBody = ({ }) const handleTableBodyContextMenu = cellContextMenuHook.handleTableBodyContextMenu + const handleTableBodyAltClick = cellContextMenuHook.handleTableBodyAltClick const { rows } = table.getRowModel() @@ -1377,6 +1378,7 @@ const TableBody = ({ display: 'grid', }} onContextMenu={handleTableBodyContextMenu} + onClick={handleTableBodyAltClick} > {columnDividerLefts.map((left, i) => ( diff --git a/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts b/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts index 6d73eaed18..d072868f91 100644 --- a/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts +++ b/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts @@ -443,7 +443,48 @@ const useCellContextMenu = ({ cellContextMenuShow(e, constructedMenuItems) } - return { handleTableBodyContextMenu } + // Alt+Click handler for expand/collapse + const handleTableBodyAltClick = (e: React.MouseEvent) => { + if (!e.altKey) return + + const target = e.target as HTMLElement + const tdEl = target.closest('td') + const cellId = tdEl?.firstElementChild?.id + + if (!cellId) return + + const cellData = getCellData(cellId) + if (!cellData) return + + // Only handle folders in the name column + if (cellData.columnId !== 'name' || cellData.entityType !== 'folder') return + + e.preventDefault() + e.stopPropagation() + + // Get selected folder IDs from current selection + const currentSelectedCells = Array.from(selectedCells) + const selectedCellsData = currentSelectedCells.flatMap((id) => getCellData(id) || []) + const selectedFolderIds = [ + ...new Set( + selectedCellsData.filter((c) => c.entityType === 'folder').map((c) => c.entityId), + ), + ] + + // Check if clicked folder is in the selection + const isClickedInSelection = selectedFolderIds.includes(cellData.entityId) + + // If clicked folder is in selection, use all selected folders; otherwise just the clicked one + const idsToToggle = isClickedInSelection ? selectedFolderIds : [cellData.entityId] + + // Check if clicked row is expanded + const isExpanded = expanded[cellData.entityId as keyof typeof expanded] + + // Toggle expand/collapse + toggleExpands(idsToToggle, !isExpanded) + } + + return { handleTableBodyContextMenu, handleTableBodyAltClick } } export default useCellContextMenu diff --git a/shared/src/containers/ProjectTreeTable/widgets/EntityNameWidget.tsx b/shared/src/containers/ProjectTreeTable/widgets/EntityNameWidget.tsx index 3d292676bb..49bad8f390 100644 --- a/shared/src/containers/ProjectTreeTable/widgets/EntityNameWidget.tsx +++ b/shared/src/containers/ProjectTreeTable/widgets/EntityNameWidget.tsx @@ -154,6 +154,7 @@ export const EntityNameWidget = ({ {isExpandable ? ( { + e.stopPropagation() if (e.altKey) { // expand/collapse all children toggleExpandAll(id) diff --git a/shared/src/containers/SimpleTable/SimpleTable.tsx b/shared/src/containers/SimpleTable/SimpleTable.tsx index 1d851a0d5e..406b657b80 100644 --- a/shared/src/containers/SimpleTable/SimpleTable.tsx +++ b/shared/src/containers/SimpleTable/SimpleTable.tsx @@ -211,6 +211,8 @@ const SimpleTable: FC = ({ onRowSelectionChange, rowPinning, onRowPinningChange, + onContextMenu, + expandAllForRow, } = useSimpleTableContext() const lastSelectedIdRef = useRef(null) const tableRef = useRef | null>(null) @@ -374,6 +376,9 @@ const SimpleTable: FC = ({ const cellMeta = cellTableInstance.options.meta const handleCellClick = (event: ReactMouseEvent) => { + // Alt+Click is handled by the RowExpander for expand/collapse + if (event.altKey) return + // Prevent row selection if clicking on an interactive element within the cell if ( event.target instanceof HTMLInputElement || @@ -422,6 +427,7 @@ const SimpleTable: FC = ({ isRowExpanded: row.getIsExpanded(), isTableExpandable: cellMeta?.isExpandable, onExpandClick: row.getToggleExpandedHandler(), + onAltExpandClick: () => expandAllForRow?.(row.id), startContent: row.original.startContent, endContent: row.original.endContent, isDisabled: row.original.isDisabled, @@ -446,6 +452,7 @@ const SimpleTable: FC = ({ enableClickToDeselect, enableNonFolderIndent, imgRatio, + expandAllForRow, ], // include enableClickToDeselect for completeness ) @@ -592,6 +599,7 @@ const SimpleTable: FC = ({ key={row.id} id={row.id} {...pt?.row} + onContextMenu={onContextMenu} style={{ transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll ...pt?.row?.style, // custom styles to be passed diff --git a/shared/src/containers/SimpleTable/SimpleTableRowTemplate.tsx b/shared/src/containers/SimpleTable/SimpleTableRowTemplate.tsx index c6d3f2b852..f2bce8783f 100644 --- a/shared/src/containers/SimpleTable/SimpleTableRowTemplate.tsx +++ b/shared/src/containers/SimpleTable/SimpleTableRowTemplate.tsx @@ -9,6 +9,7 @@ export type RowExpanderProps = { enableNonFolderIndent?: boolean isTableExpandable?: boolean onExpandClick?: () => void + onAltExpandClick?: () => void } export const RowExpander = ({ @@ -17,12 +18,17 @@ export const RowExpander = ({ enableNonFolderIndent = true, isTableExpandable, onExpandClick, + onAltExpandClick, }: RowExpanderProps) => isRowExpandable ? ( { e.stopPropagation() - onExpandClick?.() + if (e.altKey) { + onAltExpandClick?.() + } else { + onExpandClick?.() + } }} icon={isRowExpanded ? 'expand_more' : 'chevron_right'} className="expander" @@ -47,6 +53,7 @@ export interface SimpleTableCellTemplateProps extends React.HTMLAttributes void + onAltExpandClick?: () => void // when used as a template startContent?: React.ReactNode endContent?: React.ReactNode @@ -78,6 +85,7 @@ export const SimpleTableCellTemplate = forwardRef diff --git a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx index 629e39c495..5a0a8e4bb8 100644 --- a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx +++ b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx @@ -1,6 +1,44 @@ -import { ExpandedState, RowPinningState, RowSelectionState, Table } from '@tanstack/react-table' -import { createContext, useContext, ReactNode } from 'react' +import { ExpandedState, RowPinningState, RowSelectionState } from '@tanstack/react-table' +import { createContext, ReactNode, useCallback, useContext } from 'react' +import { ContextMenuItemType, useCreateContextMenu } from '@shared/containers' +import { getPlatformShortcutKey, KeyMode } from '@shared/util' +type DefaultMenuItem = 'expand-collapse' + +// Rich metadata for a single row +export type SimpleTableRowMeta = { + rowId: string + hasChildren: boolean + isExpanded: boolean + depth: number + parentId?: string +} + +// Rich context metadata passed to menu item constructors +export type SimpleTableContextMeta = { + // Row IDs (for commands) + selectedRows: string[] + + // Clicked row derived info (pre-computed) + clickedRowMeta: SimpleTableRowMeta + + // Selected rows derived info (pre-computed) + selectedRowsMeta: SimpleTableRowMeta[] + + // Aggregate info (useful for menu logic) + allSelectedHaveChildren: boolean + anySelectedHasChildren: boolean + allSelectedExpanded: boolean + anySelectedExpanded: boolean +} + +// Context menu item constructor for SimpleTable +export type SimpleTableContextMenuItemConstructor = ( + e: React.MouseEvent, + clickedRow: any, + selectedRows: any[], + meta: SimpleTableContextMeta, +) => ContextMenuItemType | ContextMenuItemType[] | undefined interface SimpleTableContextValue { // forwarded from props expanded?: ExpandedState @@ -12,6 +50,9 @@ interface SimpleTableContextValue { rowPinning?: RowPinningState onRowPinningChange?: (rowPinning: RowPinningState) => void data?: any + menuItems: (SimpleTableContextMenuItemConstructor | string)[] + onContextMenu?: (e: React.MouseEvent) => void + expandAllForRow?: (rowId: string) => void } const SimpleTableContext = createContext(undefined) @@ -27,10 +68,215 @@ interface SimpleTableProviderProps { rowPinning?: SimpleTableContextValue['rowPinning'] onRowPinningChange?: SimpleTableContextValue['onRowPinningChange'] data?: SimpleTableContextValue['data'] + menuItems?: SimpleTableContextValue['menuItems'] +} +const getAllDescendantIds = (rowId: string, data: any): string[] => { + const descendantIds: string[] = [] + + // If data is a Map, get the row directly + const row = data instanceof Map ? data.get(rowId) : null + + if (!row) return descendantIds + + const collectDescendants = (children: any[]): void => { + for (const child of children) { + descendantIds.push(child.id) + if (child.subRows && child.subRows.length > 0) { + collectDescendants(child.subRows) + } + } + } + + if (row.subRows && row.subRows.length > 0) { + collectDescendants(row.subRows) + } + + return descendantIds } -export const SimpleTableProvider = ({ children, ...props }: SimpleTableProviderProps) => { - return {children} +// Helper to compute row metadata +const getRowMeta = ( + row: any, + rowId: string, + expanded: ExpandedState | undefined, +): SimpleTableRowMeta => ({ + rowId, + hasChildren: Boolean(row?.subRows?.length), + isExpanded: Boolean(expanded && (expanded as Record)[rowId]), + depth: row?.depth ?? 0, + parentId: row?.parentId, +}) + +export const SimpleTableProvider = ({ + children, + menuItems: inputMenuItems, + ...props +}: SimpleTableProviderProps) => { + const { expanded, setExpanded, rowSelection } = props + const tableData = props.data // Map of row data + + // Context menu hook + const [cellContextMenuShow] = useCreateContextMenu([]) + + const toggleExpandAll = useCallback( + (rowIds: string[], expandAll: boolean | undefined) => { + const expandedState = typeof expanded === 'object' ? expanded : {} + const newExpandedState = { ...expandedState } + + rowIds.forEach((rowId) => { + // Get all children of the rowId using tableData + const childIds = getAllDescendantIds(rowId, tableData) + const isExpanded = expandedState[rowId] || false + + if (expandAll !== undefined ? !expandAll : isExpanded) { + // Collapse all children + newExpandedState[rowId] = false + childIds.forEach((id) => { + newExpandedState[id] = false + }) + } else { + // Expand all children + newExpandedState[rowId] = true + childIds.forEach((id) => { + newExpandedState[id] = true + }) + } + }) + + setExpanded?.(newExpandedState) + }, + [expanded, setExpanded, tableData], + ) + + const expandCollapseChildrenItems: SimpleTableContextMenuItemConstructor = ( + _e, + _clickedRow, + _selectedRows, + meta, + ) => { + // Use pre-computed metadata + const { hasChildren } = meta.clickedRowMeta + + return [ + { + label: 'Expand children', + icon: 'expand_all', + command: () => toggleExpandAll(meta.selectedRows, true), + hidden: !hasChildren, + shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), + }, + { + label: 'Collapse children', + icon: 'collapse_all', + command: () => toggleExpandAll(meta.selectedRows, false), + hidden: !hasChildren, + shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), + }, + ] + } + + const builtInMenuItems: Record = { + ['expand-collapse']: expandCollapseChildrenItems, + } + + // Context menu handler + const onContextMenu = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement + e.preventDefault() + e.stopPropagation() + const tdEl = target.closest('td') + const rowId = tdEl?.parentElement?.id + + if (!rowId || !tableData) return + + // Get clicked row data from Map + const clickedRowData = tableData.get(rowId) + if (!clickedRowData) return + + // Get selected rows IDs and data + let selectedRowsIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) + + // If no rows are selected, use the clicked row + if (selectedRowsIds.length === 0) { + selectedRowsIds = [rowId] + } + + const selectedRowsData = selectedRowsIds + .map((id) => tableData.get(id)) + .filter(Boolean) + + // Remove duplicates based on id + const filteredSelectedRowsData = [ + ...new Map(selectedRowsData.map((row: any) => [row.id, row])).values(), + ] + + // Pre-compute rich metadata + const clickedRowMeta = getRowMeta(clickedRowData, rowId, expanded) + const selectedRowsMeta = selectedRowsIds.map((id) => + getRowMeta(tableData.get(id), id, expanded), + ) + + const meta: SimpleTableContextMeta = { + selectedRows: selectedRowsIds, + clickedRowMeta, + selectedRowsMeta, + allSelectedHaveChildren: selectedRowsMeta.every((r) => r.hasChildren), + anySelectedHasChildren: selectedRowsMeta.some((r) => r.hasChildren), + allSelectedExpanded: selectedRowsMeta.every((r) => r.isExpanded), + anySelectedExpanded: selectedRowsMeta.some((r) => r.isExpanded), + } + + const constructedMenuItems = inputMenuItems?.flatMap((constructor) => + typeof constructor === 'function' + ? constructor(e, clickedRowData, filteredSelectedRowsData, meta) + : constructor in builtInMenuItems + ? builtInMenuItems[constructor as DefaultMenuItem]( + e, + clickedRowData, + filteredSelectedRowsData, + meta, + ) + : [], + ) + + cellContextMenuShow(e, constructedMenuItems) + }, + [inputMenuItems, rowSelection, builtInMenuItems, cellContextMenuShow, tableData, expanded], + ) + + const expandAllForRow = useCallback( + (rowId: string) => { + if (!rowId) return + + // Check if this row is in the current selection + const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) + const isInSelection = selectedIds.includes(rowId) + + // If the clicked row is in the selection, use all selected rows, otherwise just the clicked row + const idsToToggle = isInSelection ? selectedIds : [rowId] + + // Check if the clicked row is expanded + const isExpanded = expanded && (expanded as Record)[rowId] + + toggleExpandAll(idsToToggle, !isExpanded) + }, + [rowSelection, expanded, toggleExpandAll], + ) + + return ( + + {children} + + ) } export const useSimpleTableContext = () => { diff --git a/shared/src/containers/Slicer/components/Slicer.tsx b/shared/src/containers/Slicer/components/Slicer.tsx index c58f988948..e03a742462 100644 --- a/shared/src/containers/Slicer/components/Slicer.tsx +++ b/shared/src/containers/Slicer/components/Slicer.tsx @@ -49,6 +49,7 @@ export const Slicer: FC = ({ isLoading: isLoadingSliceTableData, } = useTableDataBySlice({ sliceFields, entityTypes }) + const handleSelectionChange = (s: RowSelectionState) => { onRowSelectionChange?.(s) } @@ -79,6 +80,7 @@ export const Slicer: FC = ({ expanded, setExpanded: onExpandedChange as React.Dispatch>, data: sliceMap, + menuItems:['expand-collapse'] }} > = ({ } }, []) - const { openContext: handleRowContext } = useListContextMenu(rowContextMenuBuilders) + const { openContext: handleRowContext, menuItems } = useListContextMenu(rowContextMenuBuilders) // Memoize the render function for the row (definition remains the same) const renderListRow = useCallback< @@ -104,7 +104,13 @@ const ListsTable: FC = ({ return ( <> = ({ onScrollBottom={fetchNextPage} enableClickToDeselect={false} meta={{ - handleRowContext, handleDoubleClick, closeRenameList, onRenameList, diff --git a/src/pages/ProjectListsPage/hooks/useListContextMenu.ts b/src/pages/ProjectListsPage/hooks/useListContextMenu.ts index 4abf90a396..9f486464c2 100644 --- a/src/pages/ProjectListsPage/hooks/useListContextMenu.ts +++ b/src/pages/ProjectListsPage/hooks/useListContextMenu.ts @@ -1,7 +1,6 @@ -import { RowSelectionState } from '@tanstack/react-table' import { useListsContext } from '../context/ListsContext' -import { CommandEvent, useCreateContextMenu } from '@shared/containers/ContextMenu' -import { useCallback } from 'react' +import { CommandEvent, ContextMenuItemType } from '@shared/containers/ContextMenu' +import { useMemo } from 'react' import { useAppSelector } from '@state/store' import useClearListItems from './useClearListItems' import { useListsDataContext } from '../context/ListsDataContext' @@ -48,12 +47,10 @@ const wouldCreateCircularDependency = ( const useListContextMenu = () => { const user = useAppSelector((state) => state.user) const developerMode = user?.attrib.developerMode - const isUser = !user.data?.isAdmin && !user.data?.isManager const { projectName } = useProjectContext() const { listsData, listFolders } = useListsDataContext() const { rowSelection, - setRowSelection, openRenameList, setListDetailsOpen, deleteLists, @@ -78,353 +75,332 @@ const useListContextMenu = () => { } const { clearListItems } = useClearListItems({ projectName }) - // create the ref and model - const [ctxMenuShow] = useCreateContextMenu() - - const handleCreateReviewSessionList: (listId: string) => void = useCallback( - async (listId) => { - await createReviewSessionList?.(listId, { - showToast: true, - navigateOnSuccess: true, - }) - }, - [createReviewSessionList, projectName], - ) - - const openContext = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - let newSelection: RowSelectionState = { ...rowSelection } - // if we are selecting a row outside of the selection (or none), set the selection to the row - if (!newSelection[e.currentTarget.id]) { - newSelection = { [e.currentTarget.id]: true } - setRowSelection(newSelection) + + // Build menu items based on current selection + const menuItems: ContextMenuItemType[] = useMemo(() => { + const newSelectedRows = Object.entries(rowSelection) + .filter(([_k, v]) => v) + .map(([k]) => k) + const newSelectedLists = listsData.filter((list) => + newSelectedRows.some((selected) => list?.id === selected), + ) + const selectedList = newSelectedLists[0] + const firstSelectedRow = Object.keys(rowSelection)[0] + const multipleSelected = Object.keys(rowSelection).length > 1 + + // Check if the first selected row is a folder + const selectedFolderId = parseListFolderRowId(firstSelectedRow) + const isSelectedRowFolder = !!selectedFolderId + const selectedFolder = isSelectedRowFolder + ? listFolders.find((f) => f.id === selectedFolderId) + : null + const selectedFolderIds = newSelectedRows + .map((id) => parseListFolderRowId(id)) + .filter((id): id is string => !!id) + + // some rows are folders + const allSelectedRowsAreLists = newSelectedRows.every((selected) => + newSelectedLists.some((list) => list?.id === selected), + ) + const allSelectedRowsAreFolders = newSelectedRows.every((selected) => + parseListFolderRowId(selected), + ) + + // Get selected folders as full objects + const selectedFoldersAll: EntityListFolderModel[] = selectedFolderIds + .map((id) => listFolders.find((f) => f.id === id)) + .filter((f): f is EntityListFolderModel => !!f) + + // Access control checks using helper functions + const userCanEditAllLists = canEditAllLists(newSelectedLists, userPermissions) + const userCanDeleteAllLists = canDeleteAllLists(newSelectedLists, userPermissions) + const userCanEditAllFolders = canEditAllFolders(selectedFoldersAll, userPermissions) + const userCanDeleteAllFolders = canDeleteAllFolders(selectedFoldersAll, userPermissions) + + // Single item access checks + const userCanEditList = selectedList ? canEditList(selectedList, userPermissions) : false + const userCanEditFolder = selectedFolder + ? canEditFolder(selectedFolder, userPermissions) + : false + + // Create recursive folder submenu + const createFolderHierarchy = ( + folders: (EntityListFolderModel & { children: EntityListFolderModel[] })[], + excludeFolderId?: string, + depth = 0, + ): any[] => { + const items: any[] = [] + + for (const folder of folders) { + if (folder.id === excludeFolderId) continue + + const hasChildren = folder.children.length > 0 + const childItems = hasChildren + ? createFolderHierarchy( + folder.children as (EntityListFolderModel & { + children: EntityListFolderModel[] + })[], + excludeFolderId, + depth + 1, + ) + : [] + + items.push({ + label: folder.label, + icon: folder.data?.icon || FOLDER_ICON, + command: allSelectedRowsAreFolders + ? () => onPutFoldersInFolder(selectedFolderIds, folder.id) + : () => + onPutListsInFolder( + newSelectedLists.map((l) => l.id), + folder.id, + ), + disabled: + allSelectedRowsAreFolders && + wouldCreateCircularDependency(selectedFolderId!, folder.id, listFolders), + ...(hasChildren && { items: childItems }), + }) } - const newSelectedRows = Object.entries(newSelection) - .filter(([_k, v]) => v) - .map(([k]) => k) - const newSelectedLists = listsData.filter((list) => - newSelectedRows.some((selected) => list?.id === selected), - ) - const selectedList = newSelectedLists[0] - const firstSelectedRow = Object.keys(newSelection)[0] - const multipleSelected = Object.keys(newSelection).length > 1 - - // Check if the first selected row is a folder - const selectedFolderId = parseListFolderRowId(firstSelectedRow) - const isSelectedRowFolder = !!selectedFolderId - const selectedFolder = isSelectedRowFolder - ? listFolders.find((f) => f.id === selectedFolderId) - : null - const selectedFolderIds = newSelectedRows - .map((id) => parseListFolderRowId(id)) - .filter((id): id is string => !!id) - - // some rows are folders - const allSelectedRowsAreLists = newSelectedRows.every((selected) => - newSelectedLists.some((list) => list?.id === selected), - ) - const allSelectedRowsAreFolders = newSelectedRows.every((selected) => - parseListFolderRowId(selected), - ) - // Get selected folders as full objects - const selectedFoldersAll: EntityListFolderModel[] = selectedFolderIds - .map((id) => listFolders.find((f) => f.id === id)) - .filter((f): f is EntityListFolderModel => !!f) - - // Access control checks using helper functions - const userCanEditAllLists = canEditAllLists(newSelectedLists, userPermissions) - const userCanDeleteAllLists = canDeleteAllLists(newSelectedLists, userPermissions) - const userCanEditAllFolders = canEditAllFolders(selectedFoldersAll, userPermissions) - const userCanDeleteAllFolders = canDeleteAllFolders(selectedFoldersAll, userPermissions) - - // Single item access checks - const userCanEditList = selectedList ? canEditList(selectedList, userPermissions) : false - const userCanEditFolder = selectedFolder - ? canEditFolder(selectedFolder, userPermissions) - : false - - // Create recursive folder submenu - const createFolderHierarchy = ( - folders: (EntityListFolderModel & { children: EntityListFolderModel[] })[], - excludeFolderId?: string, - depth = 0, - ): any[] => { - const items: any[] = [] - - for (const folder of folders) { - if (folder.id === excludeFolderId) continue - - const hasChildren = folder.children.length > 0 - const childItems = hasChildren - ? createFolderHierarchy( - folder.children as (EntityListFolderModel & { - children: EntityListFolderModel[] - })[], - excludeFolderId, - depth + 1, - ) - : [] - - items.push({ - label: folder.label, - icon: folder.data?.icon || FOLDER_ICON, - command: allSelectedRowsAreFolders - ? () => onPutFoldersInFolder(selectedFolderIds, folder.id) - : () => - onPutListsInFolder( - newSelectedLists.map((l) => l.id), - folder.id, - ), - disabled: - allSelectedRowsAreFolders && - wouldCreateCircularDependency(selectedFolderId!, folder.id, listFolders), - ...(hasChildren && { items: childItems }), - }) - } + return items + } - return items + // Create folder submenu items for lists + const createListFolderSubmenu = () => { + if (!allSelectedRowsAreLists || newSelectedLists.length === 0) { + return [] } - // Create folder submenu items for lists - const createListFolderSubmenu = () => { - if (!allSelectedRowsAreLists || newSelectedLists.length === 0) { - return [] - } - - const submenuItems: any[] = [] - const selectedListIds = newSelectedLists.map((list) => list.id) - - // Add hierarchy items first (available destination folders) - if (listFolders.length > 0) { - const { rootFolders } = buildFolderHierarchy(listFolders) - const hierarchyItems = createFolderHierarchy(rootFolders) - submenuItems.push(...hierarchyItems) - } - - // For multiple selections, show "Unset folder" if any list has a folder - // For single selection, show "Unset folder" only if that list has a folder - const hasAnyFolder = newSelectedLists.some((list) => list.entityListFolderId) - if (hasAnyFolder) { - if (submenuItems.length > 0) submenuItems.push({ separator: true }) - submenuItems.push({ - label: 'Unset folder', - icon: FOLDER_ICON_REMOVE, - command: () => { - onRemoveListsFromFolder(selectedListIds) - }, - shortcut: getPlatformShortcutKey('f', [KeyMode.Shift, KeyMode.Alt]), - }) - } + const submenuItems: any[] = [] + const selectedListIds = newSelectedLists.map((list) => list.id) - return submenuItems + // Add hierarchy items first (available destination folders) + if (listFolders.length > 0) { + const { rootFolders } = buildFolderHierarchy(listFolders) + const hierarchyItems = createFolderHierarchy(rootFolders) + submenuItems.push(...hierarchyItems) } - // Create folder submenu items for folders - const createFolderFolderSubmenu = () => { - if (!allSelectedRowsAreFolders || !selectedFolder) { - return [] - } - - const submenuItems: any[] = [] - - // Show available parent folders (excluding self and its descendants) first - const availableParents = listFolders.filter( - (folder) => - folder.id !== selectedFolderId && - !wouldCreateCircularDependency(selectedFolderId!, folder.id, listFolders), - ) - - if (availableParents.length > 0) { - const { rootFolders } = buildFolderHierarchy(availableParents) - const hierarchyItems = createFolderHierarchy(rootFolders, selectedFolderId || undefined) - submenuItems.push(...hierarchyItems) - } - - // Show "Unset parent" (make root) at bottom if folder has a parent - if (selectedFolder.parentId) { - if (submenuItems.length > 0) submenuItems.push({ separator: true }) - submenuItems.push({ - label: 'Make root folder', - icon: FOLDER_ICON_REMOVE, - command: () => onRemoveFoldersFromFolder(selectedFolderIds), - shortcut: getPlatformShortcutKey('f', [KeyMode.Shift, KeyMode.Alt]), - }) - } + // For multiple selections, show "Unset folder" if any list has a folder + // For single selection, show "Unset folder" only if that list has a folder + const hasAnyFolder = newSelectedLists.some((list) => list.entityListFolderId) + if (hasAnyFolder) { + if (submenuItems.length > 0) submenuItems.push({ separator: true }) + submenuItems.push({ + label: 'Unset folder', + icon: FOLDER_ICON_REMOVE, + command: () => { + onRemoveListsFromFolder(selectedListIds) + }, + shortcut: getPlatformShortcutKey('f', [KeyMode.Shift, KeyMode.Alt]), + }) + } + + return submenuItems + } + + // Create folder submenu items for folders + const createFolderFolderSubmenu = () => { + if (!allSelectedRowsAreFolders || !selectedFolder) { + return [] + } + + const submenuItems: any[] = [] + + // Show available parent folders (excluding self and its descendants) first + const availableParents = listFolders.filter( + (folder) => + folder.id !== selectedFolderId && + !wouldCreateCircularDependency(selectedFolderId!, folder.id, listFolders), + ) - return submenuItems + if (availableParents.length > 0) { + const { rootFolders } = buildFolderHierarchy(availableParents) + const hierarchyItems = createFolderHierarchy(rootFolders, selectedFolderId || undefined) + submenuItems.push(...hierarchyItems) } - const listFolderSubmenu = createListFolderSubmenu() - const folderFolderSubmenu = createFolderFolderSubmenu() - - // Build move submenu (formerly "Folder") - const moveMenuItems: any[] = [] - if (powerLicense) { - moveMenuItems.push({ - label: allSelectedRowsAreFolders ? 'Move folder' : 'Move list', - icon: FOLDER_ICON, - items: allSelectedRowsAreLists ? listFolderSubmenu : folderFolderSubmenu, - // Structural disabling only (no selection); ownership handled via hidden - disabled: !allSelectedRowsAreLists && !allSelectedRowsAreFolders, - hidden: - (!allSelectedRowsAreLists && !allSelectedRowsAreFolders) || - (allSelectedRowsAreLists && listFolderSubmenu.length === 0) || - (allSelectedRowsAreFolders && folderFolderSubmenu.length === 0) || - // Hide if user doesn't have edit permission on all selected items - (allSelectedRowsAreLists && !userCanEditAllLists) || - (allSelectedRowsAreFolders && !userCanEditAllFolders), + // Show "Unset parent" (make root) at bottom if folder has a parent + if (selectedFolder.parentId) { + if (submenuItems.length > 0) submenuItems.push({ separator: true }) + submenuItems.push({ + label: 'Make root folder', + icon: FOLDER_ICON_REMOVE, + command: () => onRemoveFoldersFromFolder(selectedFolderIds), + shortcut: getPlatformShortcutKey('f', [KeyMode.Shift, KeyMode.Alt]), }) } - const menuItems: any[] = [ - { - label: 'Rename', - icon: 'edit', - command: () => openRenameList(firstSelectedRow), - // Disable for multi-select - disabled: multipleSelected, - shortcut: 'R', - // Hide if not a list/folder OR user doesn't have edit permission - hidden: - (!allSelectedRowsAreLists && !isSelectedRowFolder) || - (isSelectedRowFolder && !userCanEditFolder) || - (!isSelectedRowFolder && !userCanEditList), - }, - { - label: 'Create review', - icon: 'subscriptions', - command: () => handleCreateReviewSessionList(selectedList.id), - disabled: multipleSelected || !allSelectedRowsAreLists, - hidden: !allSelectedRowsAreLists || isReview || !createReviewSessionList, - }, - { - label: 'Edit folder', - icon: FOLDER_ICON_EDIT, - command: () => { - const folderId = firstSelectedRow.replace('folder-', '') - onOpenFolderList({ folderId }) - }, - hidden: !isSelectedRowFolder || multipleSelected || !userCanEditFolder, - }, - { - label: 'Create list', - icon: 'add', - command: () => { - // If a single folder is selected, create list inside that folder - if (selectedFolderIds.length === 1) { - openNewList({ entityListFolderId: selectedFolderIds[0] }) - } else { - openNewList() - } - }, - shortcut: 'N', - hidden: !isSelectedRowFolder, - disabled: selectedFolderIds.length > 1, - }, - // Root level Create folder (lists selection) / gated if no power license - { - label: 'Create folder', - icon: FOLDER_ICON_ADD, - command: () => onOpenFolderList({}), - shortcut: 'F', - hidden: !allSelectedRowsAreLists, - powerFeature: powerLicense ? undefined : 'listFolders', - }, - // Root level Create subfolder (single folder selection) - ...(powerLicense - ? [ - { - label: 'Create subfolder', - icon: FOLDER_ICON_ADD, - command: () => onOpenFolderList({ parentId: selectedFolderIds[0] }), - shortcut: 'F', - hidden: !allSelectedRowsAreFolders || selectedFolderIds.length !== 1, - }, - ] - : []), - ...moveMenuItems, - { - label: 'Select all lists', - icon: 'checklist', - hidden: !selectedFolderIds.length, // hide if no folders selected per spec - command: () => selectAllLists({ rowIds: Object.keys(newSelection) }), + return submenuItems + } + + const listFolderSubmenu = createListFolderSubmenu() + const folderFolderSubmenu = createFolderFolderSubmenu() + + // Build move submenu (formerly "Folder") + const moveMenuItems: any[] = [] + if (powerLicense) { + moveMenuItems.push({ + label: allSelectedRowsAreFolders ? 'Move folder' : 'Move list', + icon: FOLDER_ICON, + items: allSelectedRowsAreLists ? listFolderSubmenu : folderFolderSubmenu, + // Structural disabling only (no selection); ownership handled via hidden + disabled: !allSelectedRowsAreLists && !allSelectedRowsAreFolders, + hidden: + (!allSelectedRowsAreLists && !allSelectedRowsAreFolders) || + (allSelectedRowsAreLists && listFolderSubmenu.length === 0) || + (allSelectedRowsAreFolders && folderFolderSubmenu.length === 0) || + // Hide if user doesn't have edit permission on all selected items + (allSelectedRowsAreLists && !userCanEditAllLists) || + (allSelectedRowsAreFolders && !userCanEditAllFolders), + }) + } + + const menuItems: any[] = [ + { + label: 'Rename', + icon: 'edit', + command: () => openRenameList(firstSelectedRow), + // Disable for multi-select + disabled: multipleSelected, + shortcut: 'R', + // Hide if not a list/folder OR user doesn't have edit permission + hidden: + (!allSelectedRowsAreLists && !isSelectedRowFolder) || + (isSelectedRowFolder && !userCanEditFolder) || + (!isSelectedRowFolder && !userCanEditList), + }, + { + label: 'Create review', + icon: 'subscriptions', + command: async () => { + await createReviewSessionList?.(selectedList.id, { + showToast: true, + navigateOnSuccess: true, + }) }, - { - label: 'Details', - icon: 'info', - command: () => setListDetailsOpen(true), - disabled: multipleSelected, - hidden: !allSelectedRowsAreLists, - shortcut: 'Double click', + disabled: multipleSelected || !allSelectedRowsAreLists, + hidden: !allSelectedRowsAreLists || isReview || !createReviewSessionList, + }, + { + label: 'Edit folder', + icon: FOLDER_ICON_EDIT, + command: () => { + const folderId = firstSelectedRow.replace('folder-', '') + onOpenFolderList({ folderId }) }, - { - label: 'Clear list', - icon: 'close', - developer: true, - command: () => clearListItems(firstSelectedRow), - hidden: !developerMode || multipleSelected || !allSelectedRowsAreLists, + hidden: !isSelectedRowFolder || multipleSelected || !userCanEditFolder, + }, + { + label: 'Create list', + icon: 'add', + command: () => { + // If a single folder is selected, create list inside that folder + if (selectedFolderIds.length === 1) { + openNewList({ entityListFolderId: selectedFolderIds[0] }) + } else { + openNewList() + } }, - { - label: `Delete ${allSelectedRowsAreFolders ? '(folder only)' : ''}`, - icon: 'delete', - danger: true, - command: (e: CommandEvent) => { - const forceDelete = e.originalEvent.metaKey || e.originalEvent.ctrlKey - - if (allSelectedRowsAreFolders) { - const folderIds = newSelectedRows - .map((rowId) => parseListFolderRowId(rowId)) - .filter((id): id is string => !!id) - - // Delete folders - onDeleteListFolders(folderIds) - } else if (allSelectedRowsAreLists) { - // Delete lists - deleteLists(Object.keys(newSelection), { force: forceDelete }) - } - }, - // Hide if not lists/folders OR user doesn't have delete permission - hidden: - (!allSelectedRowsAreLists && !allSelectedRowsAreFolders) || - (allSelectedRowsAreLists && !userCanDeleteAllLists) || - (allSelectedRowsAreFolders && !userCanDeleteAllFolders), + shortcut: 'N', + hidden: !isSelectedRowFolder, + disabled: selectedFolderIds.length > 1, + }, + // Root level Create folder (lists selection) / gated if no power license + { + label: 'Create folder', + icon: FOLDER_ICON_ADD, + command: () => onOpenFolderList({}), + shortcut: 'F', + hidden: !allSelectedRowsAreLists, + powerFeature: powerLicense ? undefined : 'listFolders', + }, + // Root level Create subfolder (single folder selection) + ...(powerLicense + ? [ + { + label: 'Create subfolder', + icon: FOLDER_ICON_ADD, + command: () => onOpenFolderList({ parentId: selectedFolderIds[0] }), + shortcut: 'F', + hidden: !allSelectedRowsAreFolders || selectedFolderIds.length !== 1, + }, + ] + : []), + ...moveMenuItems, + { + label: 'Select all lists', + icon: 'checklist', + hidden: !selectedFolderIds.length, // hide if no folders selected per spec + command: () => selectAllLists({ rowIds: Object.keys(rowSelection) }), + }, + { + label: 'Details', + icon: 'info', + command: () => setListDetailsOpen(true), + disabled: multipleSelected, + hidden: !allSelectedRowsAreLists, + shortcut: 'Double click', + }, + { + label: 'Clear list', + icon: 'close', + developer: true, + command: () => clearListItems(firstSelectedRow), + hidden: !developerMode || multipleSelected || !allSelectedRowsAreLists, + }, + { + label: `Delete ${allSelectedRowsAreFolders ? '(folder only)' : ''}`, + icon: 'delete', + danger: true, + command: (e: CommandEvent) => { + const forceDelete = e.originalEvent.metaKey || e.originalEvent.ctrlKey + + if (allSelectedRowsAreFolders) { + const folderIds = newSelectedRows + .map((rowId) => parseListFolderRowId(rowId)) + .filter((id): id is string => !!id) + + // Delete folders + onDeleteListFolders(folderIds) + } else if (allSelectedRowsAreLists) { + // Delete lists + deleteLists(Object.keys(rowSelection), { force: forceDelete }) + } }, - ] - - ctxMenuShow(e, menuItems) - }, - [ - ctxMenuShow, - rowSelection, - listsData, - listFolders, - setRowSelection, - openRenameList, - setListDetailsOpen, - deleteLists, - createReviewSessionList, - onPutListsInFolder, - onRemoveListsFromFolder, - onOpenFolderList, - onDeleteListFolders, - onPutFoldersInFolder, - onRemoveFoldersFromFolder, - isUser, - developerMode, - handleCreateReviewSessionList, - clearListItems, - isReview, - powerLicense, - ], - ) - - return { - openContext, - } + // Hide if not lists/folders OR user doesn't have delete permission + hidden: + (!allSelectedRowsAreLists && !allSelectedRowsAreFolders) || + (allSelectedRowsAreLists && !userCanDeleteAllLists) || + (allSelectedRowsAreFolders && !userCanDeleteAllFolders), + }, + ] + + return menuItems + }, [ + rowSelection, + listsData, + listFolders, + openRenameList, + setListDetailsOpen, + deleteLists, + createReviewSessionList, + onPutListsInFolder, + onRemoveListsFromFolder, + onOpenFolderList, + openNewList, + onDeleteListFolders, + onPutFoldersInFolder, + onRemoveFoldersFromFolder, + selectAllLists, + developerMode, + clearListItems, + isReview, + powerLicense, + userPermissions, + ]) + + return { menuItems } } export default useListContextMenu