From 9dc9936d3419b7a97bc4838525c0ea082212424f Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Fri, 5 Dec 2025 11:54:37 +0100 Subject: [PATCH 01/12] (refactor): Add `useSectionMenuItems` hook and integrate context menu handling in `Slicer` component --- src/containers/Slicer/Slicer.tsx | 80 ++++++++++++++++++- .../Slicer/hooks/useSectionMenuItems.tsx | 53 ++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 src/containers/Slicer/hooks/useSectionMenuItems.tsx diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index 78468e4a3..2052d8f69 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from 'react' +import { FC, useCallback, useEffect, useState } from 'react' import SimpleTable, { Container, Header } from '@shared/containers/SimpleTable' import useTableDataBySlice from './hooks/useTableDataBySlice' @@ -9,6 +9,8 @@ import { SimpleTableProvider } from '@shared/containers/SimpleTable' import { useSlicerContext } from '@context/SlicerContext' import { RowSelectionState } from '@tanstack/react-table' import { SliceTypeField } from './types' +import { useCreateContextMenu } from '@shared/containers' +import useSectionMenuItems from '@containers/Slicer/hooks/useSectionMenuItems.tsx' interface SlicerProps { sliceFields: SliceTypeField[] @@ -16,7 +18,11 @@ interface SlicerProps { persistFieldId?: SliceType // when changing slice type, leavePersistentSlice the selected field } -const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], persistFieldId }) => { +const Slicer: FC = ({ + sliceFields = [], + entityTypes = ['task'], + persistFieldId, +}) => { const [globalFilter, setGlobalFilter] = useState('') const { SlicerDropdown, @@ -37,6 +43,71 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per isLoading: isLoadingSliceTableData, } = useTableDataBySlice({ sliceFields, entityTypes }) + const [ctxMenuShow] = useCreateContextMenu() + + const handleExpand = useCallback( + (selectedIds: string[]) => { + const currentExpanded = expanded || {} + const newExpanded = { ...(currentExpanded as Record) } + selectedIds.forEach((id) => { + newExpanded[id] = true + }) + setExpanded(newExpanded) + }, + [expanded, setExpanded], + ) + + const handleCollapse = useCallback( + (selectedIds: string[]) => { + const currentExpanded = expanded || {} + const newExpanded = { ...(currentExpanded as Record) } + selectedIds.forEach((id) => { + delete newExpanded[id] + }) + setExpanded(newExpanded) + }, + [expanded, setExpanded], + ) + + const handleDelete = useCallback( + (selectedIds: string[]) => { + console.log('Delete selected items:', selectedIds) + // Add your delete logic here + }, + [], + ) + + const buildMenuItems = useSectionMenuItems({ + onDelete: handleDelete, + onExpand: handleExpand, + onCollapse: handleCollapse, + }) + + const menuItems = buildMenuItems(rowSelection, { + command: true, + dividers: false, + // hidden: { + // 'add-project': true, + // search: true, + // 'select-all': true, + // }, + }) + console.log(rowSelection) + const openContext = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + + if (!menuItems) return + // contextMenuItems is already an array of menu items, pass it directly + + ctxMenuShow(e, menuItems) + }, + [ctxMenuShow, menuItems], + ) + + const handleSelectionChange = (s: RowSelectionState) => { setRowSelection(s) onRowSelectionChange?.(s, sliceMap) @@ -88,6 +159,11 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per isLoading={isLoadingSliceTableData} forceUpdateTable={sliceType} globalFilter={globalFilter} + pt={{ + row: { + onContextMenu: openContext, + }, + }} /> diff --git a/src/containers/Slicer/hooks/useSectionMenuItems.tsx b/src/containers/Slicer/hooks/useSectionMenuItems.tsx new file mode 100644 index 000000000..adf40f73c --- /dev/null +++ b/src/containers/Slicer/hooks/useSectionMenuItems.tsx @@ -0,0 +1,53 @@ +import { useCallback } from 'react' + +type UseSectionMenuItemsProps = { + onDelete?: (selectedIds: string[]) => void + onExpand?: (selectedIds: string[]) => void + onCollapse?: (selectedIds: string[]) => void +} + +type BuildOptions = { + command?: boolean + dividers?: boolean + hidden?: Record +} + +const useSectionMenuItems = ({ onDelete, onExpand, onCollapse }: UseSectionMenuItemsProps) => { + + + return useCallback( + (rowSelection: Record, options: BuildOptions = {}) => { + const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) + const multipleSelected = selectedIds.length > 1 + const hasSelection = selectedIds.length > 0 + + return [ + { + label: 'Expand All', + icon: 'unfold_more', + command: () => onExpand?.(selectedIds), + hidden: options.hidden?.['expand-all'] || multipleSelected || !hasSelection, + }, + { + label: 'Collapse All', + icon: 'unfold_less', + command: () => onCollapse?.(selectedIds), + hidden: options.hidden?.['collapse-all'] || multipleSelected || !hasSelection, + }, + { + separator: !options.dividers, + }, + { + label: 'Delete', + icon: 'delete', + danger: true, + command: () => onDelete?.(selectedIds), + hidden: options.hidden?.['delete'] || !hasSelection, + }, + ].filter((item) => !item.hidden) + }, + [onDelete, onExpand, onCollapse], + ) +} + +export default useSectionMenuItems From b7498bb6af8505178162f0c18ad2be0000c0cf8b Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Fri, 5 Dec 2025 15:34:19 +0100 Subject: [PATCH 02/12] (refactor): Replace `useSectionMenuItems` with `useSlicerMenuItems` for improved context menu handling and code organization --- src/containers/Slicer/Slicer.tsx | 75 +-------- .../Slicer/hooks/useSectionMenuItems.tsx | 53 ------ .../Slicer/hooks/useSlicerMenuItems.tsx | 156 ++++++++++++++++++ 3 files changed, 165 insertions(+), 119 deletions(-) delete mode 100644 src/containers/Slicer/hooks/useSectionMenuItems.tsx create mode 100644 src/containers/Slicer/hooks/useSlicerMenuItems.tsx diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index 2052d8f69..d242527e2 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useEffect, useState } from 'react' +import { FC, useEffect, useState } from 'react' import SimpleTable, { Container, Header } from '@shared/containers/SimpleTable' import useTableDataBySlice from './hooks/useTableDataBySlice' @@ -9,8 +9,7 @@ import { SimpleTableProvider } from '@shared/containers/SimpleTable' import { useSlicerContext } from '@context/SlicerContext' import { RowSelectionState } from '@tanstack/react-table' import { SliceTypeField } from './types' -import { useCreateContextMenu } from '@shared/containers' -import useSectionMenuItems from '@containers/Slicer/hooks/useSectionMenuItems.tsx' +import useSlicerMenuItems from '@containers/Slicer/hooks/useSlicerMenuItems.tsx' interface SlicerProps { sliceFields: SliceTypeField[] @@ -43,70 +42,14 @@ const Slicer: FC = ({ isLoading: isLoadingSliceTableData, } = useTableDataBySlice({ sliceFields, entityTypes }) - const [ctxMenuShow] = useCreateContextMenu() - - const handleExpand = useCallback( - (selectedIds: string[]) => { - const currentExpanded = expanded || {} - const newExpanded = { ...(currentExpanded as Record) } - selectedIds.forEach((id) => { - newExpanded[id] = true - }) - setExpanded(newExpanded) - }, - [expanded, setExpanded], - ) - - const handleCollapse = useCallback( - (selectedIds: string[]) => { - const currentExpanded = expanded || {} - const newExpanded = { ...(currentExpanded as Record) } - selectedIds.forEach((id) => { - delete newExpanded[id] - }) - setExpanded(newExpanded) - }, - [expanded, setExpanded], - ) - - const handleDelete = useCallback( - (selectedIds: string[]) => { - console.log('Delete selected items:', selectedIds) - // Add your delete logic here - }, - [], - ) - - const buildMenuItems = useSectionMenuItems({ - onDelete: handleDelete, - onExpand: handleExpand, - onCollapse: handleCollapse, - }) - - const menuItems = buildMenuItems(rowSelection, { - command: true, - dividers: false, - // hidden: { - // 'add-project': true, - // search: true, - // 'select-all': true, - // }, + // Context menu hook handles all menu logic + const { openContext } = useSlicerMenuItems({ + expanded, + setExpanded, + rowSelection, + setRowSelection, + tableData: sliceTableData, }) - console.log(rowSelection) - const openContext = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - - if (!menuItems) return - // contextMenuItems is already an array of menu items, pass it directly - - ctxMenuShow(e, menuItems) - }, - [ctxMenuShow, menuItems], - ) - const handleSelectionChange = (s: RowSelectionState) => { setRowSelection(s) diff --git a/src/containers/Slicer/hooks/useSectionMenuItems.tsx b/src/containers/Slicer/hooks/useSectionMenuItems.tsx deleted file mode 100644 index adf40f73c..000000000 --- a/src/containers/Slicer/hooks/useSectionMenuItems.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback } from 'react' - -type UseSectionMenuItemsProps = { - onDelete?: (selectedIds: string[]) => void - onExpand?: (selectedIds: string[]) => void - onCollapse?: (selectedIds: string[]) => void -} - -type BuildOptions = { - command?: boolean - dividers?: boolean - hidden?: Record -} - -const useSectionMenuItems = ({ onDelete, onExpand, onCollapse }: UseSectionMenuItemsProps) => { - - - return useCallback( - (rowSelection: Record, options: BuildOptions = {}) => { - const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) - const multipleSelected = selectedIds.length > 1 - const hasSelection = selectedIds.length > 0 - - return [ - { - label: 'Expand All', - icon: 'unfold_more', - command: () => onExpand?.(selectedIds), - hidden: options.hidden?.['expand-all'] || multipleSelected || !hasSelection, - }, - { - label: 'Collapse All', - icon: 'unfold_less', - command: () => onCollapse?.(selectedIds), - hidden: options.hidden?.['collapse-all'] || multipleSelected || !hasSelection, - }, - { - separator: !options.dividers, - }, - { - label: 'Delete', - icon: 'delete', - danger: true, - command: () => onDelete?.(selectedIds), - hidden: options.hidden?.['delete'] || !hasSelection, - }, - ].filter((item) => !item.hidden) - }, - [onDelete, onExpand, onCollapse], - ) -} - -export default useSectionMenuItems diff --git a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx new file mode 100644 index 000000000..155fa033e --- /dev/null +++ b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx @@ -0,0 +1,156 @@ +import { useCallback } from 'react' +import { useCreateContextMenu } from '@shared/containers' +import { ExpandedState, RowSelectionState } from '@tanstack/react-table' +import { SimpleTableRow } from '@shared/containers/SimpleTable' + +type UseSectionMenuItemsProps = { + expanded: ExpandedState + setExpanded: (expanded: ExpandedState) => void + rowSelection: RowSelectionState + setRowSelection: (selection: RowSelectionState) => void + tableData: SimpleTableRow[] // The table data to traverse for children +} + +const useSlicerMenuItems = ({ + expanded, + setExpanded, + rowSelection, + setRowSelection, + tableData, +}: UseSectionMenuItemsProps) => { + const [ctxMenuShow] = useCreateContextMenu() + + // Helper function: Recursively get all descendant IDs from a row + const getAllDescendantIds = useCallback((rowId: string, data: SimpleTableRow[]): string[] => { + const descendantIds: string[] = [] + + // Find the row in the data + const findRowAndDescendants = (rows: SimpleTableRow[]): void => { + for (const row of rows) { + if (row.id === rowId) { + // Found the row, now get all its descendants + const collectDescendants = (children: SimpleTableRow[]): 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 + } + // Continue searching in subRows + if (row.subRows && row.subRows.length > 0) { + findRowAndDescendants(row.subRows) + } + } + } + + findRowAndDescendants(data) + return descendantIds + }, []) + + const handleExpand = useCallback( + (selectedIds: string[]) => { + const currentExpanded = expanded || {} + const newExpanded = { ...(currentExpanded as Record) } + + selectedIds.forEach((id) => { + // Add the folder itself + newExpanded[id] = true + + // Add ALL its descendants (children, grandchildren, etc.) + const descendantIds = getAllDescendantIds(id, tableData) + descendantIds.forEach((descendantId) => { + newExpanded[descendantId] = true + }) + }) + + setExpanded(newExpanded) + }, + [expanded, setExpanded, tableData, getAllDescendantIds], + ) + + const handleCollapse = useCallback( + (selectedIds: string[]) => { + const currentExpanded = expanded || {} + const newExpanded = { ...(currentExpanded as Record) } + + selectedIds.forEach((id) => { + // Remove the folder itself + delete newExpanded[id] + + // Remove ALL its descendants (children, grandchildren, etc.) + const descendantIds = getAllDescendantIds(id, tableData) + descendantIds.forEach((descendantId) => { + delete newExpanded[descendantId] + }) + }) + + setExpanded(newExpanded) + }, + [expanded, setExpanded, tableData, getAllDescendantIds], + ) + + const handleDelete = useCallback( + (selectedIds: string[]) => { + console.log('Delete selected items:', selectedIds) + // Add your delete logic here + }, + [], + ) + + const openContext = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + // Get the clicked row ID from the event target + const clickedRowId = e.currentTarget.id + + // Temporary selection: if clicked row is not in current selection, select it temporarily + let newSelection: RowSelectionState = { ...rowSelection } + if (!newSelection[clickedRowId]) { + newSelection = { [clickedRowId]: true } + setRowSelection(newSelection) + } + + // Get selected IDs based on the new selection + const selectedIds = Object.keys(newSelection).filter((id) => newSelection[id]) + const multipleSelected = selectedIds.length > 1 + const hasSelection = selectedIds.length > 0 + + const menuItems = [ + { + label: 'Expand All', + icon: 'unfold_more', + command: () => handleExpand(selectedIds), + hidden: multipleSelected || !hasSelection, + }, + { + label: 'Collapse All', + icon: 'unfold_less', + command: () => handleCollapse(selectedIds), + hidden: multipleSelected || !hasSelection, + }, + { + label: 'Delete', + icon: 'delete', + danger: true, + command: () => handleDelete(selectedIds), + hidden: !hasSelection, + }, + ].filter((item) => !item.hidden) + + ctxMenuShow(e, menuItems) + }, + [ctxMenuShow, rowSelection, handleExpand, handleCollapse, handleDelete], + ) + + return { openContext } +} + +export default useSlicerMenuItems From 43efc3b2e3a1b8701dcf35cde40b3610dcc4dabe Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Mon, 8 Dec 2025 16:01:47 +0100 Subject: [PATCH 03/12] (refactor): Enhance `useSlicerMenuItems` hook with additional actions and improved context menu logic in `Slicer` component --- shared/src/api/generated/folders.ts | 1 + src/containers/Slicer/Slicer.tsx | 38 +- .../Slicer/hooks/useSlicerMenuItems.tsx | 325 +++++++++++++++++- 3 files changed, 348 insertions(+), 16 deletions(-) diff --git a/shared/src/api/generated/folders.ts b/shared/src/api/generated/folders.ts index 36686eb55..3d3db0557 100644 --- a/shared/src/api/generated/folders.ts +++ b/shared/src/api/generated/folders.ts @@ -228,6 +228,7 @@ export type FolderListItem = { status: string attrib?: object ownAttrib?: string[] + hasVersions: boolean createdAt: string updatedAt: string } diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index d242527e2..214507e7f 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -10,6 +10,8 @@ import { useSlicerContext } from '@context/SlicerContext' import { RowSelectionState } from '@tanstack/react-table' import { SliceTypeField } from './types' import useSlicerMenuItems from '@containers/Slicer/hooks/useSlicerMenuItems.tsx' +import { RenameForm } from '@shared/components/RenameForm' +import { Dialog } from '@ynput/ayon-react-components' interface SlicerProps { sliceFields: SliceTypeField[] @@ -17,11 +19,7 @@ interface SlicerProps { persistFieldId?: SliceType // when changing slice type, leavePersistentSlice the selected field } -const Slicer: FC = ({ - sliceFields = [], - entityTypes = ['task'], - persistFieldId, -}) => { +const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], persistFieldId, }) => { const [globalFilter, setGlobalFilter] = useState('') const { SlicerDropdown, @@ -43,7 +41,7 @@ const Slicer: FC = ({ } = useTableDataBySlice({ sliceFields, entityTypes }) // Context menu hook handles all menu logic - const { openContext } = useSlicerMenuItems({ + const { openContext, renameDialog, handleSubmitRename, handleCancelRename } = useSlicerMenuItems({ expanded, setExpanded, rowSelection, @@ -109,6 +107,34 @@ const Slicer: FC = ({ }} /> + + {/*{renameDialog && (*/} + {/* */} + {/* {*/} + {/* await handleSubmitRename(field as 'name' | 'label', value)*/} + {/* },*/} + {/* },*/} + {/* entityRowId: renameDialog.entityId,*/} + {/* hasVersions: false,*/} + {/* }}*/} + {/* />*/} + {/* */} + {/*)}*/} ) } diff --git a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx index 155fa033e..f69a4d9b2 100644 --- a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx +++ b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx @@ -1,26 +1,54 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { useCreateContextMenu } from '@shared/containers' import { ExpandedState, RowSelectionState } from '@tanstack/react-table' import { SimpleTableRow } from '@shared/containers/SimpleTable' +import { useMoveEntityContext, useProjectFoldersContext, useDetailsPanelContext } from '@shared/context' +import { useUpdateOverviewEntitiesMutation } from '@shared/api' +import { confirmDelete } from '@shared/util' +import { toast } from 'react-toastify' +import { useAppSelector } from '@state/store' +import { copyToClipboard } from '@shared/util' +import { DetailsPanelEntityType } from '@shared/api' -type UseSectionMenuItemsProps = { +type UseSlicerMenuItemsProps = { expanded: ExpandedState setExpanded: (expanded: ExpandedState) => void rowSelection: RowSelectionState setRowSelection: (selection: RowSelectionState) => void - tableData: SimpleTableRow[] // The table data to traverse for children + tableData: SimpleTableRow[] } +type RenameDialogState = { + entityId: string + entityType: 'folder' | 'task' + currentName: string + currentLabel: string +} | null + const useSlicerMenuItems = ({ expanded, setExpanded, rowSelection, setRowSelection, tableData, -}: UseSectionMenuItemsProps) => { +}: UseSlicerMenuItemsProps) => { const [ctxMenuShow] = useCreateContextMenu() + const { openMoveDialog } = useMoveEntityContext() + const { folders } = useProjectFoldersContext() + const [updateOverviewEntities] = useUpdateOverviewEntitiesMutation() + const projectName = useAppSelector((state) => state.project.name) + const { setEntities, setPanelOpen } = useDetailsPanelContext() + const [renameDialog, setRenameDialog] = useState(null) + + const mapSubTypeToEntityType = useCallback((subType: string | null | undefined): 'folder' | 'task' | null => { + if (subType === 'Folder' || subType === 'Asset') { + return 'folder' + } else if (subType === 'Task') { + return 'task' + } + return null + }, []) - // Helper function: Recursively get all descendant IDs from a row const getAllDescendantIds = useCallback((rowId: string, data: SimpleTableRow[]): string[] => { const descendantIds: string[] = [] @@ -95,14 +123,262 @@ const useSlicerMenuItems = ({ [expanded, setExpanded, tableData, getAllDescendantIds], ) + // Helper function to find a row by ID in the table data + const findRowById = useCallback((rows: SimpleTableRow[], id: string): SimpleTableRow | undefined => { + for (const row of rows) { + if (row.id === id) return row + if (row.subRows?.length) { + const found = findRowById(row.subRows, id) + if (found) return found + } + } + return undefined + }, []) + const handleDelete = useCallback( + async (selectedIds: string[]) => { + if (!selectedIds || selectedIds.length === 0) { + toast.error('No entities selected') + return + } + + // Find the entities from the table data + const entitiesToDelete = selectedIds + .map((id) => { + const row = findRowById(tableData, id) + if (!row) return null + + const entityType = mapSubTypeToEntityType(row.data?.subType) + if (!entityType) return null + + return { + entityId: row.data?.id || row.id, + entityType, + label: row.label || row.name, + rowId: row.id, + } + }) + .filter((entity): entity is NonNullable => entity !== null) + + if (entitiesToDelete.length === 0) { + toast.error('No entities found') + return + } + + const deleteEntities = async (force = false) => { + const operations = entitiesToDelete.map((e) => ({ + entityType: e.entityType, + type: 'delete' as const, + entityId: e.entityId, + force, + })) + + try { + await updateOverviewEntities({ + projectName: projectName || '', + operationsRequestModel: { operations }, + }).unwrap() + // Clear selection after successful delete + setRowSelection({}) + } catch (error: any) { + const message = error?.error || 'Failed to delete entities' + console.error('Failed to delete entities:', error) + throw { message, ...error } + } + } + + const entityLabel = + entitiesToDelete.length === 1 + ? `"${entitiesToDelete[0].label}"` + : `${entitiesToDelete.length} entities` + + confirmDelete({ + label: 'folders and tasks', + message: `Are you sure you want to delete ${entityLabel}? This action cannot be undone.`, + accept: deleteEntities, + onError: (error: any) => { + const FOLDER_WITH_CHILDREN_CODE = 'delete-folder-with-children' + // check if the error is because of child tasks, products + if (error?.errorCodes?.includes(FOLDER_WITH_CHILDREN_CODE)) { + const confirmForce = window.confirm( + `Are you really sure you want to delete ${entityLabel} and all of its dependencies? This cannot be undone. (NOT RECOMMENDED)`, + ) + if (confirmForce) { + deleteEntities(true) + } else { + console.log('User cancelled forced delete') + } + } + }, + deleteLabel: 'Delete forever', + }) + }, + [tableData, updateOverviewEntities, projectName, setRowSelection, findRowById, mapSubTypeToEntityType], + ) + + const handleMove = useCallback( + (selectedIds: string[]) => { + const entitiesToMove = selectedIds + .map((id) => { + const row = findRowById(tableData, id) + if (!row) return null + + const entityType = mapSubTypeToEntityType(row.data?.subType) + if (!entityType) return null + + return { + entityId: row.data?.id || row.id, + entityType, + currentParentId: row.parentId, + } + }) + .filter((entity): entity is NonNullable => entity !== null) + + const hasUnmovableFolders = entitiesToMove.some((entity) => { + if (entity.entityType === 'folder') { + const folderData = folders.find((f) => f.id === entity.entityId) + return folderData?.hasVersions + } + return false + }) + + if (hasUnmovableFolders) { + return + } + + if (entitiesToMove.length === 1) { + openMoveDialog(entitiesToMove[0]) + } else if (entitiesToMove.length > 1) { + openMoveDialog({ entities: entitiesToMove }) + } + }, + [tableData, findRowById, mapSubTypeToEntityType, folders, openMoveDialog], + ) + + const copy = useCallback( + (selectedIds: string[]) => { + const entitiesToCopy = selectedIds + .map((id) => { + const row = findRowById(tableData, id) + if (!row) return null + return row.name + }) + .filter((name): name is string => name !== null) + + copyToClipboard(entitiesToCopy.join('\n')) + }, + [tableData, findRowById], + ) + + const handleOpenDetailsPanel = useCallback( (selectedIds: string[]) => { - console.log('Delete selected items:', selectedIds) - // Add your delete logic here + if (!projectName) { + toast.error('No project selected') + return + } + + const entities = selectedIds + .map((id) => { + const row = findRowById(tableData, id) + if (!row || !row.data?.id) return null + + const entityType = mapSubTypeToEntityType(row.data?.subType) as DetailsPanelEntityType | null + if (!entityType) return null + + return { + id: row.data.id, + projectName, + entityType, + } + }) + .filter((entity): entity is NonNullable => entity !== null) + + if (entities.length === 0) { + toast.error('No valid entities to show in details panel') + return + } + + const primaryEntityType = entities[0].entityType + + setEntities({ + entityType: primaryEntityType, + entities: entities.map((e) => ({ id: e.id, projectName: e.projectName })), + }) + setPanelOpen('slicer', true) }, - [], + [projectName, tableData, findRowById, mapSubTypeToEntityType, setEntities, setPanelOpen], ) + const handleRename = useCallback( + (selectedIds: string[]) => { + if (!projectName) { + toast.error('No project selected') + return + } + + if (selectedIds.length !== 1) { + toast.error('Please select only one entity to rename') + return + } + + const row = findRowById(tableData, selectedIds[0]) + if (!row || !row.data?.id) { + toast.error('Entity not found') + return + } + + const entityType = mapSubTypeToEntityType(row.data?.subType) + if (!entityType) { + toast.error('Cannot rename this entity type') + return + } + + setRenameDialog({ + entityId: row.data.id, + entityType, + currentName: row.data?.name || row.name || '', + currentLabel: row.data?.label || row.label || '', + }) + }, + [projectName, tableData, findRowById, mapSubTypeToEntityType], + ) + + const handleSubmitRename = useCallback( + async (field: 'name' | 'label', value: string) => { + if (!renameDialog || !projectName) return + + try { + const operation = { + entityType: renameDialog.entityType, + type: 'update' as const, + entityId: renameDialog.entityId, + data: { [field]: value.trim() }, + } + + await updateOverviewEntities({ + projectName, + operationsRequestModel: { operations: [operation] }, + }).unwrap() + + // Update the rename dialog state to reflect the change + setRenameDialog({ + ...renameDialog, + [field === 'name' ? 'currentName' : 'currentLabel']: value, + }) + } catch (error: any) { + const message = error?.error || error?.data?.detail || `Failed to update ${field}` + console.error(`Failed to update ${field}:`, error) + toast.error(message) + throw error + } + }, + [renameDialog, projectName, updateOverviewEntities], + ) + + const handleCancelRename = useCallback(() => { + setRenameDialog(null) + }, []) + const openContext = useCallback( (e: React.MouseEvent) => { e.preventDefault() @@ -124,6 +400,30 @@ const useSlicerMenuItems = ({ const hasSelection = selectedIds.length > 0 const menuItems = [ + { + label: 'Copy', + icon: 'content_copy', + command: copy, + hidden: !hasSelection, + }, + { + label: 'Details', + icon: 'info', + command: () => handleOpenDetailsPanel(selectedIds), + hidden: !hasSelection, + }, + // { + // label: 'Rename', + // icon: 'titlecase', + // command: () => handleRename(selectedIds), + // hidden: multipleSelected || !hasSelection, + // }, + { + label: 'Move', + icon: 'drive_file_move', + command: () => handleMove(selectedIds), + hidden: !hasSelection, + }, { label: 'Expand All', icon: 'unfold_more', @@ -147,10 +447,15 @@ const useSlicerMenuItems = ({ ctxMenuShow(e, menuItems) }, - [ctxMenuShow, rowSelection, handleExpand, handleCollapse, handleDelete], + [ctxMenuShow, rowSelection, setRowSelection, handleExpand, handleCollapse, handleDelete, handleMove, handleOpenDetailsPanel, handleRename, copy], ) - return { openContext } + return { + openContext, + renameDialog, + handleSubmitRename, + handleCancelRename, + } } export default useSlicerMenuItems From 2b160a26f1869c8aedfe77b0a493b830e2237b5b Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Tue, 9 Dec 2025 16:38:18 +0100 Subject: [PATCH 04/12] (refactor): Remove rename dialog logic, add keyboard shortcuts, and update context menu in `Slicer` component --- src/containers/Slicer/Slicer.tsx | 41 +----- .../Slicer/hooks/useSlicerMenuItems.tsx | 128 +++++++----------- 2 files changed, 54 insertions(+), 115 deletions(-) diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index 214507e7f..ca00ac520 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -10,8 +10,7 @@ import { useSlicerContext } from '@context/SlicerContext' import { RowSelectionState } from '@tanstack/react-table' import { SliceTypeField } from './types' import useSlicerMenuItems from '@containers/Slicer/hooks/useSlicerMenuItems.tsx' -import { RenameForm } from '@shared/components/RenameForm' -import { Dialog } from '@ynput/ayon-react-components' +import Shortcuts from '@containers/Shortcuts' interface SlicerProps { sliceFields: SliceTypeField[] @@ -19,7 +18,7 @@ interface SlicerProps { persistFieldId?: SliceType // when changing slice type, leavePersistentSlice the selected field } -const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], persistFieldId, }) => { +const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], persistFieldId }) => { const [globalFilter, setGlobalFilter] = useState('') const { SlicerDropdown, @@ -40,8 +39,8 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per isLoading: isLoadingSliceTableData, } = useTableDataBySlice({ sliceFields, entityTypes }) - // Context menu hook handles all menu logic - const { openContext, renameDialog, handleSubmitRename, handleCancelRename } = useSlicerMenuItems({ + // Context menu hook handles all menu logic and keyboard shortcuts + const { openContext, handleRowClick, shortcuts } = useSlicerMenuItems({ expanded, setExpanded, rowSelection, @@ -67,6 +66,7 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per return ( +
= ({ sliceFields = [], entityTypes = ['task'], per pt={{ row: { onContextMenu: openContext, - }, + onClickCapture: handleRowClick, + } }} /> - - {/*{renameDialog && (*/} - {/* */} - {/* {*/} - {/* await handleSubmitRename(field as 'name' | 'label', value)*/} - {/* },*/} - {/* },*/} - {/* entityRowId: renameDialog.entityId,*/} - {/* hasVersions: false,*/} - {/* }}*/} - {/* />*/} - {/* */} - {/*)}*/} ) } diff --git a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx index f69a4d9b2..4bb615561 100644 --- a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx +++ b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx @@ -1,10 +1,10 @@ -import { useCallback, useState } from 'react' +import { useCallback, useMemo } from 'react' import { useCreateContextMenu } from '@shared/containers' import { ExpandedState, RowSelectionState } from '@tanstack/react-table' import { SimpleTableRow } from '@shared/containers/SimpleTable' import { useMoveEntityContext, useProjectFoldersContext, useDetailsPanelContext } from '@shared/context' import { useUpdateOverviewEntitiesMutation } from '@shared/api' -import { confirmDelete } from '@shared/util' +import { confirmDelete, getPlatformShortcutKey, KeyMode } from '@shared/util' import { toast } from 'react-toastify' import { useAppSelector } from '@state/store' import { copyToClipboard } from '@shared/util' @@ -18,13 +18,6 @@ type UseSlicerMenuItemsProps = { tableData: SimpleTableRow[] } -type RenameDialogState = { - entityId: string - entityType: 'folder' | 'task' - currentName: string - currentLabel: string -} | null - const useSlicerMenuItems = ({ expanded, setExpanded, @@ -38,7 +31,6 @@ const useSlicerMenuItems = ({ const [updateOverviewEntities] = useUpdateOverviewEntitiesMutation() const projectName = useAppSelector((state) => state.project.name) const { setEntities, setPanelOpen } = useDetailsPanelContext() - const [renameDialog, setRenameDialog] = useState(null) const mapSubTypeToEntityType = useCallback((subType: string | null | undefined): 'folder' | 'task' | null => { if (subType === 'Folder' || subType === 'Asset') { @@ -309,76 +301,37 @@ const useSlicerMenuItems = ({ [projectName, tableData, findRowById, mapSubTypeToEntityType, setEntities, setPanelOpen], ) - const handleRename = useCallback( - (selectedIds: string[]) => { - if (!projectName) { - toast.error('No project selected') - return - } - - if (selectedIds.length !== 1) { - toast.error('Please select only one entity to rename') - return - } + // Handle alt+click for expand/collapse + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + if (event.altKey) { + event.preventDefault() + event.stopPropagation() - const row = findRowById(tableData, selectedIds[0]) - if (!row || !row.data?.id) { - toast.error('Entity not found') - return - } + // Get the row ID from the event target + const rowId = event.currentTarget.id + if (!rowId) return - const entityType = mapSubTypeToEntityType(row.data?.subType) - if (!entityType) { - toast.error('Cannot rename this entity type') - return - } + // Check if this row is in the current selection + const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) + const isInSelection = selectedIds.includes(rowId) - setRenameDialog({ - entityId: row.data.id, - entityType, - currentName: row.data?.name || row.name || '', - currentLabel: row.data?.label || row.label || '', - }) - }, - [projectName, tableData, findRowById, mapSubTypeToEntityType], - ) + // If the clicked row is in the selection, use all selected rows, otherwise just the clicked row + const idsToToggle = isInSelection ? selectedIds : [rowId] - const handleSubmitRename = useCallback( - async (field: 'name' | 'label', value: string) => { - if (!renameDialog || !projectName) return + // Check if the clicked row is expanded + const isExpanded = expanded && (expanded as Record)[rowId] - try { - const operation = { - entityType: renameDialog.entityType, - type: 'update' as const, - entityId: renameDialog.entityId, - data: { [field]: value.trim() }, + if (isExpanded) { + handleCollapse(idsToToggle) + } else { + handleExpand(idsToToggle) } - - await updateOverviewEntities({ - projectName, - operationsRequestModel: { operations: [operation] }, - }).unwrap() - - // Update the rename dialog state to reflect the change - setRenameDialog({ - ...renameDialog, - [field === 'name' ? 'currentName' : 'currentLabel']: value, - }) - } catch (error: any) { - const message = error?.error || error?.data?.detail || `Failed to update ${field}` - console.error(`Failed to update ${field}:`, error) - toast.error(message) - throw error } }, - [renameDialog, projectName, updateOverviewEntities], + [rowSelection, expanded, handleExpand, handleCollapse], ) - const handleCancelRename = useCallback(() => { - setRenameDialog(null) - }, []) - const openContext = useCallback( (e: React.MouseEvent) => { e.preventDefault() @@ -403,8 +356,9 @@ const useSlicerMenuItems = ({ { label: 'Copy', icon: 'content_copy', - command: copy, + command: () => copy(selectedIds), hidden: !hasSelection, + shortcut: getPlatformShortcutKey('c', [KeyMode.Ctrl]), }, { label: 'Details', @@ -412,12 +366,6 @@ const useSlicerMenuItems = ({ command: () => handleOpenDetailsPanel(selectedIds), hidden: !hasSelection, }, - // { - // label: 'Rename', - // icon: 'titlecase', - // command: () => handleRename(selectedIds), - // hidden: multipleSelected || !hasSelection, - // }, { label: 'Move', icon: 'drive_file_move', @@ -429,12 +377,14 @@ const useSlicerMenuItems = ({ icon: 'unfold_more', command: () => handleExpand(selectedIds), hidden: multipleSelected || !hasSelection, + shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), }, { label: 'Collapse All', icon: 'unfold_less', command: () => handleCollapse(selectedIds), hidden: multipleSelected || !hasSelection, + shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), }, { label: 'Delete', @@ -447,14 +397,30 @@ const useSlicerMenuItems = ({ ctxMenuShow(e, menuItems) }, - [ctxMenuShow, rowSelection, setRowSelection, handleExpand, handleCollapse, handleDelete, handleMove, handleOpenDetailsPanel, handleRename, copy], + [ctxMenuShow, rowSelection, setRowSelection, handleExpand, handleCollapse, handleDelete, handleMove, handleOpenDetailsPanel, copy], ) + // Keyboard shortcuts + const shortcuts = useMemo void }>>(() => { + const getSelectedIds = () => Object.keys(rowSelection).filter((id) => rowSelection[id]) + + return [ + { + key: 'ctrl+c', + action: () => { + const selectedIds = getSelectedIds() + if (selectedIds.length > 0) { + copy(selectedIds) + } + }, + }, + ] + }, [rowSelection, copy]) + return { openContext, - renameDialog, - handleSubmitRename, - handleCancelRename, + handleRowClick, + shortcuts, } } From 8c5ca2a15245f3c88dcfe02d05d4922f53176f00 Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Wed, 10 Dec 2025 11:23:29 +0100 Subject: [PATCH 05/12] (refactor): Simplify `useSlicerMenuItems` by removing unused logic, dependencies, and context menu actions in `Slicer` component --- src/containers/Slicer/Slicer.tsx | 8 +- .../Slicer/hooks/useSlicerMenuItems.tsx | 255 +----------------- 2 files changed, 6 insertions(+), 257 deletions(-) diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index ca00ac520..e277e71f4 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -9,8 +9,7 @@ import { SimpleTableProvider } from '@shared/containers/SimpleTable' import { useSlicerContext } from '@context/SlicerContext' import { RowSelectionState } from '@tanstack/react-table' import { SliceTypeField } from './types' -import useSlicerMenuItems from '@containers/Slicer/hooks/useSlicerMenuItems.tsx' -import Shortcuts from '@containers/Shortcuts' +import useSlicerMenuItems from '@containers/Slicer/hooks/useSlicerMenuItems' interface SlicerProps { sliceFields: SliceTypeField[] @@ -40,7 +39,7 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per } = useTableDataBySlice({ sliceFields, entityTypes }) // Context menu hook handles all menu logic and keyboard shortcuts - const { openContext, handleRowClick, shortcuts } = useSlicerMenuItems({ + const { openContext, handleRowClick } = useSlicerMenuItems({ expanded, setExpanded, rowSelection, @@ -66,7 +65,6 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per return ( -
= ({ sliceFields = [], entityTypes = ['task'], per row: { onContextMenu: openContext, onClickCapture: handleRowClick, - } + }, }} /> diff --git a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx index 4bb615561..85f1db419 100644 --- a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx +++ b/src/containers/Slicer/hooks/useSlicerMenuItems.tsx @@ -1,14 +1,8 @@ -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { useCreateContextMenu } from '@shared/containers' import { ExpandedState, RowSelectionState } from '@tanstack/react-table' import { SimpleTableRow } from '@shared/containers/SimpleTable' -import { useMoveEntityContext, useProjectFoldersContext, useDetailsPanelContext } from '@shared/context' -import { useUpdateOverviewEntitiesMutation } from '@shared/api' -import { confirmDelete, getPlatformShortcutKey, KeyMode } from '@shared/util' -import { toast } from 'react-toastify' -import { useAppSelector } from '@state/store' -import { copyToClipboard } from '@shared/util' -import { DetailsPanelEntityType } from '@shared/api' +import { getPlatformShortcutKey, KeyMode } from '@shared/util' type UseSlicerMenuItemsProps = { expanded: ExpandedState @@ -26,20 +20,6 @@ const useSlicerMenuItems = ({ tableData, }: UseSlicerMenuItemsProps) => { const [ctxMenuShow] = useCreateContextMenu() - const { openMoveDialog } = useMoveEntityContext() - const { folders } = useProjectFoldersContext() - const [updateOverviewEntities] = useUpdateOverviewEntitiesMutation() - const projectName = useAppSelector((state) => state.project.name) - const { setEntities, setPanelOpen } = useDetailsPanelContext() - - const mapSubTypeToEntityType = useCallback((subType: string | null | undefined): 'folder' | 'task' | null => { - if (subType === 'Folder' || subType === 'Asset') { - return 'folder' - } else if (subType === 'Task') { - return 'task' - } - return null - }, []) const getAllDescendantIds = useCallback((rowId: string, data: SimpleTableRow[]): string[] => { const descendantIds: string[] = [] @@ -115,192 +95,6 @@ const useSlicerMenuItems = ({ [expanded, setExpanded, tableData, getAllDescendantIds], ) - // Helper function to find a row by ID in the table data - const findRowById = useCallback((rows: SimpleTableRow[], id: string): SimpleTableRow | undefined => { - for (const row of rows) { - if (row.id === id) return row - if (row.subRows?.length) { - const found = findRowById(row.subRows, id) - if (found) return found - } - } - return undefined - }, []) - - const handleDelete = useCallback( - async (selectedIds: string[]) => { - if (!selectedIds || selectedIds.length === 0) { - toast.error('No entities selected') - return - } - - // Find the entities from the table data - const entitiesToDelete = selectedIds - .map((id) => { - const row = findRowById(tableData, id) - if (!row) return null - - const entityType = mapSubTypeToEntityType(row.data?.subType) - if (!entityType) return null - - return { - entityId: row.data?.id || row.id, - entityType, - label: row.label || row.name, - rowId: row.id, - } - }) - .filter((entity): entity is NonNullable => entity !== null) - - if (entitiesToDelete.length === 0) { - toast.error('No entities found') - return - } - - const deleteEntities = async (force = false) => { - const operations = entitiesToDelete.map((e) => ({ - entityType: e.entityType, - type: 'delete' as const, - entityId: e.entityId, - force, - })) - - try { - await updateOverviewEntities({ - projectName: projectName || '', - operationsRequestModel: { operations }, - }).unwrap() - // Clear selection after successful delete - setRowSelection({}) - } catch (error: any) { - const message = error?.error || 'Failed to delete entities' - console.error('Failed to delete entities:', error) - throw { message, ...error } - } - } - - const entityLabel = - entitiesToDelete.length === 1 - ? `"${entitiesToDelete[0].label}"` - : `${entitiesToDelete.length} entities` - - confirmDelete({ - label: 'folders and tasks', - message: `Are you sure you want to delete ${entityLabel}? This action cannot be undone.`, - accept: deleteEntities, - onError: (error: any) => { - const FOLDER_WITH_CHILDREN_CODE = 'delete-folder-with-children' - // check if the error is because of child tasks, products - if (error?.errorCodes?.includes(FOLDER_WITH_CHILDREN_CODE)) { - const confirmForce = window.confirm( - `Are you really sure you want to delete ${entityLabel} and all of its dependencies? This cannot be undone. (NOT RECOMMENDED)`, - ) - if (confirmForce) { - deleteEntities(true) - } else { - console.log('User cancelled forced delete') - } - } - }, - deleteLabel: 'Delete forever', - }) - }, - [tableData, updateOverviewEntities, projectName, setRowSelection, findRowById, mapSubTypeToEntityType], - ) - - const handleMove = useCallback( - (selectedIds: string[]) => { - const entitiesToMove = selectedIds - .map((id) => { - const row = findRowById(tableData, id) - if (!row) return null - - const entityType = mapSubTypeToEntityType(row.data?.subType) - if (!entityType) return null - - return { - entityId: row.data?.id || row.id, - entityType, - currentParentId: row.parentId, - } - }) - .filter((entity): entity is NonNullable => entity !== null) - - const hasUnmovableFolders = entitiesToMove.some((entity) => { - if (entity.entityType === 'folder') { - const folderData = folders.find((f) => f.id === entity.entityId) - return folderData?.hasVersions - } - return false - }) - - if (hasUnmovableFolders) { - return - } - - if (entitiesToMove.length === 1) { - openMoveDialog(entitiesToMove[0]) - } else if (entitiesToMove.length > 1) { - openMoveDialog({ entities: entitiesToMove }) - } - }, - [tableData, findRowById, mapSubTypeToEntityType, folders, openMoveDialog], - ) - - const copy = useCallback( - (selectedIds: string[]) => { - const entitiesToCopy = selectedIds - .map((id) => { - const row = findRowById(tableData, id) - if (!row) return null - return row.name - }) - .filter((name): name is string => name !== null) - - copyToClipboard(entitiesToCopy.join('\n')) - }, - [tableData, findRowById], - ) - - const handleOpenDetailsPanel = useCallback( - (selectedIds: string[]) => { - if (!projectName) { - toast.error('No project selected') - return - } - - const entities = selectedIds - .map((id) => { - const row = findRowById(tableData, id) - if (!row || !row.data?.id) return null - - const entityType = mapSubTypeToEntityType(row.data?.subType) as DetailsPanelEntityType | null - if (!entityType) return null - - return { - id: row.data.id, - projectName, - entityType, - } - }) - .filter((entity): entity is NonNullable => entity !== null) - - if (entities.length === 0) { - toast.error('No valid entities to show in details panel') - return - } - - const primaryEntityType = entities[0].entityType - - setEntities({ - entityType: primaryEntityType, - entities: entities.map((e) => ({ id: e.id, projectName: e.projectName })), - }) - setPanelOpen('slicer', true) - }, - [projectName, tableData, findRowById, mapSubTypeToEntityType, setEntities, setPanelOpen], - ) - // Handle alt+click for expand/collapse const handleRowClick = useCallback( (event: React.MouseEvent) => { @@ -353,25 +147,6 @@ const useSlicerMenuItems = ({ const hasSelection = selectedIds.length > 0 const menuItems = [ - { - label: 'Copy', - icon: 'content_copy', - command: () => copy(selectedIds), - hidden: !hasSelection, - shortcut: getPlatformShortcutKey('c', [KeyMode.Ctrl]), - }, - { - label: 'Details', - icon: 'info', - command: () => handleOpenDetailsPanel(selectedIds), - hidden: !hasSelection, - }, - { - label: 'Move', - icon: 'drive_file_move', - command: () => handleMove(selectedIds), - hidden: !hasSelection, - }, { label: 'Expand All', icon: 'unfold_more', @@ -386,41 +161,17 @@ const useSlicerMenuItems = ({ hidden: multipleSelected || !hasSelection, shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), }, - { - label: 'Delete', - icon: 'delete', - danger: true, - command: () => handleDelete(selectedIds), - hidden: !hasSelection, - }, ].filter((item) => !item.hidden) ctxMenuShow(e, menuItems) }, - [ctxMenuShow, rowSelection, setRowSelection, handleExpand, handleCollapse, handleDelete, handleMove, handleOpenDetailsPanel, copy], + [ctxMenuShow, rowSelection, setRowSelection, handleExpand, handleCollapse], ) - // Keyboard shortcuts - const shortcuts = useMemo void }>>(() => { - const getSelectedIds = () => Object.keys(rowSelection).filter((id) => rowSelection[id]) - - return [ - { - key: 'ctrl+c', - action: () => { - const selectedIds = getSelectedIds() - if (selectedIds.length > 0) { - copy(selectedIds) - } - }, - }, - ] - }, [rowSelection, copy]) return { openContext, handleRowClick, - shortcuts, } } From a47eb4c9a50ef27bbd58b11820a0da0176660ff5 Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Thu, 11 Dec 2025 14:11:18 +0100 Subject: [PATCH 06/12] (refactor): Enhance SimpleTable context for dynamic menu items and context menu handling - Added support for `menuItems` and `onContextMenu` in `SimpleTableContext`. - Introduced context menu logic and descendant ID handling for expand/collapse actions. - Renamed `useSlicerMenuItems` to `useSimpleTableMenu` for consistency and updated related references. - Refactored `useListContextMenu` to return precomputed `menuItems` instead of handling full context logic. - Updated `Slicer` and `ListsTable` components to utilize new menu item handling and context menu actions. --- .../containers/SimpleTable/SimpleTable.tsx | 2 + .../context/SimpleTableContext.tsx | 183 +++++++++++++++++- src/containers/Slicer/Slicer.tsx | 6 +- ...erMenuItems.tsx => useSimpleTableMenu.tsx} | 8 +- .../components/ListsTable/ListsTable.tsx | 5 +- .../hooks/useListContextMenu.ts | 118 +++++------ 6 files changed, 238 insertions(+), 84 deletions(-) rename src/containers/Slicer/hooks/{useSlicerMenuItems.tsx => useSimpleTableMenu.tsx} (97%) diff --git a/shared/src/containers/SimpleTable/SimpleTable.tsx b/shared/src/containers/SimpleTable/SimpleTable.tsx index 374df45fc..7cc1d7db3 100644 --- a/shared/src/containers/SimpleTable/SimpleTable.tsx +++ b/shared/src/containers/SimpleTable/SimpleTable.tsx @@ -207,6 +207,7 @@ const SimpleTable: FC = ({ onRowSelectionChange, rowPinning, onRowPinningChange, + onContextMenu } = useSimpleTableContext() const lastSelectedIdRef = useRef(null) const tableRef = useRef | null>(null) @@ -587,6 +588,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/context/SimpleTableContext.tsx b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx index 629e39c49..cc6d02925 100644 --- a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx +++ b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx @@ -1,5 +1,7 @@ import { ExpandedState, RowPinningState, RowSelectionState, Table } from '@tanstack/react-table' -import { createContext, useContext, ReactNode } from 'react' +import { createContext, useContext, ReactNode, useMemo, useCallback } from 'react' +import { ContextMenuItemType, useCreateContextMenu } from '@shared/containers' +import { getPlatformShortcutKey, KeyMode } from '@shared/util' interface SimpleTableContextValue { // forwarded from props @@ -12,6 +14,8 @@ interface SimpleTableContextValue { rowPinning?: RowPinningState onRowPinningChange?: (rowPinning: RowPinningState) => void data?: any + menuItems: (ContextMenuItemType | string)[] + onContextMenu?: (e: React.MouseEvent) => void } const SimpleTableContext = createContext(undefined) @@ -27,10 +31,183 @@ interface SimpleTableProviderProps { rowPinning?: SimpleTableContextValue['rowPinning'] onRowPinningChange?: SimpleTableContextValue['onRowPinningChange'] data?: SimpleTableContextValue['data'] + menuItems?: SimpleTableContextValue['menuItems'] } +const getAllDescendantIds = (rowId: string, data: any[]): string[] => { + const descendantIds: string[] = [] -export const SimpleTableProvider = ({ children, ...props }: SimpleTableProviderProps) => { - return {children} + const findRowAndDescendants = (rows: any[]): void => { + for (const row of rows) { + if (row.id === rowId) { + 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 + } + if (row.subRows && row.subRows.length > 0) { + findRowAndDescendants(row.subRows) + } + } + } + findRowAndDescendants(data) + return descendantIds +} +export const SimpleTableProvider = ({ children, menuItems: inputMenuItems, ...props }: SimpleTableProviderProps) => { + const { expanded, setExpanded, rowSelection, setRowSelection } = props + const tableData = props.data // Assuming this is the tanstack-table data structure (sliceTableData) + + // Context menu hook + const [ctxMenuShow] = useCreateContextMenu() + + // --- Expansion/Collapse Command Handlers --- + const handleExpand = useCallback( + (selectedIds: string[]) => { + const currentExpanded = expanded || {} + const newExpanded = { ...(currentExpanded as Record) } + + selectedIds.forEach((id) => { + newExpanded[id] = true + const descendantIds = getAllDescendantIds(id, tableData) + descendantIds.forEach((descendantId) => { + newExpanded[descendantId] = true + }) + }) + + setExpanded?.(newExpanded) + }, + [expanded, setExpanded, tableData], + ) + + const handleCollapse = useCallback( + (selectedIds: string[]) => { + const currentExpanded = expanded || {} + const newExpanded = { ...(currentExpanded as Record) } + + selectedIds.forEach((id) => { + delete newExpanded[id] + const descendantIds = getAllDescendantIds(id, tableData) + descendantIds.forEach((descendantId) => { + delete newExpanded[descendantId] + }) + }) + + setExpanded?.(newExpanded) + }, + [expanded, setExpanded, tableData], + ) + + // Helper function to compute menu items based on selected IDs + const computeMenuItems = useCallback( + (selectedIds: string[]) => { + if (!Array.isArray(inputMenuItems)) return [] + + const items: ContextMenuItemType[] = [] + const hasSelection = selectedIds.length > 0 + const multipleSelected = selectedIds.length > 1 +console.log('computeMenuItems', inputMenuItems) + // Process each menu item + inputMenuItems.forEach((item) => { + if (typeof item === 'string') { + if (item === 'expand-collapse') { + const handleExpandSelected = () => handleExpand(selectedIds) + const handleCollapseSelected = () => handleCollapse(selectedIds) + + const isAllSelectedExpanded = selectedIds.every( + (id) => expanded && (expanded as Record)[id], + ) + const isAllSelectedCollapsed = selectedIds.every( + (id) => !expanded || !(expanded as Record)[id], + ) + + const expandCollapseItems: ContextMenuItemType[] = [ + { + label: 'Expand All', + icon: 'unfold_more', + command: handleExpandSelected, + hidden: isAllSelectedExpanded, + disabled: !hasSelection || multipleSelected, + shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), + }, + { + label: 'Collapse All', + icon: 'unfold_less', + command: handleCollapseSelected, + hidden: isAllSelectedCollapsed, + disabled: !hasSelection || multipleSelected, + shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), + }, + ].filter((menuItem) => !menuItem.hidden) + + if (expandCollapseItems.length > 0 && items.length > 0) { + items.push({ separator: true }) + } + items.push(...expandCollapseItems) + } + } else { + items.push(item) + } + }) + + return items + }, + [inputMenuItems, expanded, handleExpand, handleCollapse], + ) + + // --- Menu Item Construction (for display purposes) --- + const finalMenuItems: ContextMenuItemType[] = useMemo(() => { + const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) + return computeMenuItems(selectedIds) + }, [computeMenuItems, rowSelection]) + + // Context menu handler + const onContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + console.log('onContextMenu event:', e) + console.log('e.currentTarget:', e.currentTarget) + console.log('e.currentTarget.id:', e.currentTarget.id) + + // Get the clicked row ID from the event target + const clickedRowId = e.currentTarget.id + console.log('clickedRowId:', clickedRowId) + console.log('rowSelection:', rowSelection) + + // Determine the effective selection (current or the clicked row if not selected) + let effectiveSelection = rowSelection + if (clickedRowId && !rowSelection[clickedRowId]) { + effectiveSelection = { [clickedRowId]: true } + console.log('Updated effectiveSelection:', effectiveSelection) + // Update the actual selection + if (setRowSelection) { + setRowSelection(effectiveSelection) + } + } + + // Compute menu items based on effective selection + const effectiveSelectedIds = Object.keys(effectiveSelection).filter((id) => effectiveSelection[id]) + console.log('effectiveSelectedIds:', effectiveSelectedIds) + const menuItemsToShow = computeMenuItems(effectiveSelectedIds) + console.log('menuItemsToShow:', menuItemsToShow) + // Show context menu + if (menuItemsToShow.length > 0) { + ctxMenuShow(e, menuItemsToShow) + } else { + console.warn('No menu items to show!') + } + }, + [ctxMenuShow, rowSelection, setRowSelection, computeMenuItems], + ) + + return {children} } export const useSimpleTableContext = () => { diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index e277e71f4..bccb93432 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -9,7 +9,7 @@ import { SimpleTableProvider } from '@shared/containers/SimpleTable' import { useSlicerContext } from '@context/SlicerContext' import { RowSelectionState } from '@tanstack/react-table' import { SliceTypeField } from './types' -import useSlicerMenuItems from '@containers/Slicer/hooks/useSlicerMenuItems' +import useSimpleTableMenu from '@containers/Slicer/hooks/useSimpleTableMenu' interface SlicerProps { sliceFields: SliceTypeField[] @@ -39,7 +39,7 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per } = useTableDataBySlice({ sliceFields, entityTypes }) // Context menu hook handles all menu logic and keyboard shortcuts - const { openContext, handleRowClick } = useSlicerMenuItems({ + const { handleRowClick } = useSimpleTableMenu({ expanded, setExpanded, rowSelection, @@ -90,6 +90,7 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per setExpanded, onExpandedChange, data: sliceMap, + menuItems:['expand-collapse'] }} > = ({ sliceFields = [], entityTypes = ['task'], per globalFilter={globalFilter} pt={{ row: { - onContextMenu: openContext, onClickCapture: handleRowClick, }, }} diff --git a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx b/src/containers/Slicer/hooks/useSimpleTableMenu.tsx similarity index 97% rename from src/containers/Slicer/hooks/useSlicerMenuItems.tsx rename to src/containers/Slicer/hooks/useSimpleTableMenu.tsx index 85f1db419..d09610c6a 100644 --- a/src/containers/Slicer/hooks/useSlicerMenuItems.tsx +++ b/src/containers/Slicer/hooks/useSimpleTableMenu.tsx @@ -4,7 +4,7 @@ import { ExpandedState, RowSelectionState } from '@tanstack/react-table' import { SimpleTableRow } from '@shared/containers/SimpleTable' import { getPlatformShortcutKey, KeyMode } from '@shared/util' -type UseSlicerMenuItemsProps = { +type UseSimpleTableMenuProps = { expanded: ExpandedState setExpanded: (expanded: ExpandedState) => void rowSelection: RowSelectionState @@ -12,13 +12,13 @@ type UseSlicerMenuItemsProps = { tableData: SimpleTableRow[] } -const useSlicerMenuItems = ({ +const useSimpleTableMenu = ({ expanded, setExpanded, rowSelection, setRowSelection, tableData, -}: UseSlicerMenuItemsProps) => { +}: UseSimpleTableMenuProps) => { const [ctxMenuShow] = useCreateContextMenu() const getAllDescendantIds = useCallback((rowId: string, data: SimpleTableRow[]): string[] => { @@ -175,4 +175,4 @@ const useSlicerMenuItems = ({ } } -export default useSlicerMenuItems +export default useSimpleTableMenu diff --git a/src/pages/ProjectListsPage/components/ListsTable/ListsTable.tsx b/src/pages/ProjectListsPage/components/ListsTable/ListsTable.tsx index 3f25c6606..4f203342a 100644 --- a/src/pages/ProjectListsPage/components/ListsTable/ListsTable.tsx +++ b/src/pages/ProjectListsPage/components/ListsTable/ListsTable.tsx @@ -40,7 +40,7 @@ const ListsTable: FC = ({ isReview }) => { } }, []) - const { openContext: handleRowContext } = useListContextMenu() + const { menuItems } = useListContextMenu() // Memoize the render function for the row (definition remains the same) const renderListRow = useCallback< @@ -88,7 +88,7 @@ const ListsTable: FC = ({ isReview }) => { return ( <> = ({ isReview }) => { 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 7b844f280..3657e8a3f 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' -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' @@ -71,12 +70,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, @@ -101,39 +98,18 @@ 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) - } - 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 + + // 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) @@ -319,7 +295,12 @@ const useListContextMenu = () => { { label: 'Create review', icon: 'subscriptions', - command: () => handleCreateReviewSessionList(selectedList.id), + command: async () => { + await createReviewSessionList?.(selectedList.id, { + showToast: true, + navigateOnSuccess: true, + }) + }, disabled: multipleSelected || !allSelectedRowsAreLists, hidden: !allSelectedRowsAreLists || isReview || !createReviewSessionList, }, @@ -373,7 +354,7 @@ const useListContextMenu = () => { label: 'Select all lists', icon: 'checklist', hidden: !selectedFolderIds.length, // hide if no folders selected per spec - command: () => selectAllLists({ rowIds: Object.keys(newSelection) }), + command: () => selectAllLists({ rowIds: Object.keys(rowSelection) }), }, { label: 'Details', @@ -406,7 +387,7 @@ const useListContextMenu = () => { onDeleteListFolders(folderIds) } else if (allSelectedRowsAreLists) { // Delete lists - deleteLists(Object.keys(newSelection), { force: forceDelete }) + deleteLists(Object.keys(rowSelection), { force: forceDelete }) } }, // Hide if not lists/folders OR user doesn't have delete permission @@ -417,36 +398,31 @@ const useListContextMenu = () => { }, ] - 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, - } + 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 From 208e81746d5cc4daa6f9182822b91760db17c99a Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Thu, 11 Dec 2025 14:13:34 +0100 Subject: [PATCH 07/12] (refactor): Remove redundant console logs from `SimpleTableContext` for cleaner debugging and improved readability --- .../SimpleTable/context/SimpleTableContext.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx index cc6d02925..f02736958 100644 --- a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx +++ b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx @@ -112,7 +112,7 @@ export const SimpleTableProvider = ({ children, menuItems: inputMenuItems, ...pr const items: ContextMenuItemType[] = [] const hasSelection = selectedIds.length > 0 const multipleSelected = selectedIds.length > 1 -console.log('computeMenuItems', inputMenuItems) + // Process each menu item inputMenuItems.forEach((item) => { if (typeof item === 'string') { @@ -172,20 +172,16 @@ console.log('computeMenuItems', inputMenuItems) (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - console.log('onContextMenu event:', e) - console.log('e.currentTarget:', e.currentTarget) - console.log('e.currentTarget.id:', e.currentTarget.id) + // Get the clicked row ID from the event target const clickedRowId = e.currentTarget.id - console.log('clickedRowId:', clickedRowId) - console.log('rowSelection:', rowSelection) // Determine the effective selection (current or the clicked row if not selected) let effectiveSelection = rowSelection if (clickedRowId && !rowSelection[clickedRowId]) { effectiveSelection = { [clickedRowId]: true } - console.log('Updated effectiveSelection:', effectiveSelection) + // Update the actual selection if (setRowSelection) { setRowSelection(effectiveSelection) @@ -194,14 +190,12 @@ console.log('computeMenuItems', inputMenuItems) // Compute menu items based on effective selection const effectiveSelectedIds = Object.keys(effectiveSelection).filter((id) => effectiveSelection[id]) - console.log('effectiveSelectedIds:', effectiveSelectedIds) + const menuItemsToShow = computeMenuItems(effectiveSelectedIds) - console.log('menuItemsToShow:', menuItemsToShow) + // Show context menu if (menuItemsToShow.length > 0) { ctxMenuShow(e, menuItemsToShow) - } else { - console.warn('No menu items to show!') } }, [ctxMenuShow, rowSelection, setRowSelection, computeMenuItems], From 2e8171999820e86c9f7a99b5086921600690d909 Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Thu, 11 Dec 2025 15:07:44 +0100 Subject: [PATCH 08/12] (refactor): Remove `useSimpleTableMenu` and migrate alt-click expand/collapse logic to `SimpleTableContext` - Eliminated the `useSimpleTableMenu` hook from Slicer. - Integrated alt-click handling for expand/collapse directly into `SimpleTableContext`. - Updated `Slicer` component to use the refactored context handling via `SlicerTable`. --- .../context/SimpleTableContext.tsx | 34 +++- src/containers/Slicer/Slicer.tsx | 50 +++-- .../Slicer/hooks/useSimpleTableMenu.tsx | 178 ------------------ 3 files changed, 64 insertions(+), 198 deletions(-) delete mode 100644 src/containers/Slicer/hooks/useSimpleTableMenu.tsx diff --git a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx index f02736958..2274ac707 100644 --- a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx +++ b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx @@ -16,6 +16,7 @@ interface SimpleTableContextValue { data?: any menuItems: (ContextMenuItemType | string)[] onContextMenu?: (e: React.MouseEvent) => void + handleAltClick?: (e: React.MouseEvent) => void } const SimpleTableContext = createContext(undefined) @@ -201,7 +202,38 @@ export const SimpleTableProvider = ({ children, menuItems: inputMenuItems, ...pr [ctxMenuShow, rowSelection, setRowSelection, computeMenuItems], ) - return {children} + // Alt+Click handler for expand/collapse + const handleAltClick = useCallback( + (e: React.MouseEvent) => { + if (e.altKey) { + e.preventDefault() + e.stopPropagation() + + // Get the row ID from the event target + const rowId = e.currentTarget.id + 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] + + if (isExpanded) { + handleCollapse(idsToToggle) + } else { + handleExpand(idsToToggle) + } + } + }, + [rowSelection, expanded, handleExpand, handleCollapse], + ) + + return {children} } export const useSimpleTableContext = () => { diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index bccb93432..0ad4127ef 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react' -import SimpleTable, { Container, Header } from '@shared/containers/SimpleTable' +import SimpleTable, { Container, Header, useSimpleTableContext } from '@shared/containers/SimpleTable' import useTableDataBySlice from './hooks/useTableDataBySlice' import SlicerSearch from './SlicerSearch' @@ -9,7 +9,6 @@ import { SimpleTableProvider } from '@shared/containers/SimpleTable' import { useSlicerContext } from '@context/SlicerContext' import { RowSelectionState } from '@tanstack/react-table' import { SliceTypeField } from './types' -import useSimpleTableMenu from '@containers/Slicer/hooks/useSimpleTableMenu' interface SlicerProps { sliceFields: SliceTypeField[] @@ -17,6 +16,32 @@ interface SlicerProps { persistFieldId?: SliceType // when changing slice type, leavePersistentSlice the selected field } +// Inner component that has access to SimpleTableContext +const SlicerTable: FC<{ + sliceTableData: any + isExpandable: boolean + isLoadingSliceTableData: boolean + sliceType: string + globalFilter: string +}> = ({ sliceTableData, isExpandable, isLoadingSliceTableData, sliceType, globalFilter }) => { + const { handleAltClick } = useSimpleTableContext() + + return ( + + ) +} + const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], persistFieldId }) => { const [globalFilter, setGlobalFilter] = useState('') const { @@ -38,14 +63,6 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per isLoading: isLoadingSliceTableData, } = useTableDataBySlice({ sliceFields, entityTypes }) - // Context menu hook handles all menu logic and keyboard shortcuts - const { handleRowClick } = useSimpleTableMenu({ - expanded, - setExpanded, - rowSelection, - setRowSelection, - tableData: sliceTableData, - }) const handleSelectionChange = (s: RowSelectionState) => { setRowSelection(s) @@ -93,17 +110,12 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per menuItems:['expand-collapse'] }} > - diff --git a/src/containers/Slicer/hooks/useSimpleTableMenu.tsx b/src/containers/Slicer/hooks/useSimpleTableMenu.tsx deleted file mode 100644 index d09610c6a..000000000 --- a/src/containers/Slicer/hooks/useSimpleTableMenu.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useCallback } from 'react' -import { useCreateContextMenu } from '@shared/containers' -import { ExpandedState, RowSelectionState } from '@tanstack/react-table' -import { SimpleTableRow } from '@shared/containers/SimpleTable' -import { getPlatformShortcutKey, KeyMode } from '@shared/util' - -type UseSimpleTableMenuProps = { - expanded: ExpandedState - setExpanded: (expanded: ExpandedState) => void - rowSelection: RowSelectionState - setRowSelection: (selection: RowSelectionState) => void - tableData: SimpleTableRow[] -} - -const useSimpleTableMenu = ({ - expanded, - setExpanded, - rowSelection, - setRowSelection, - tableData, -}: UseSimpleTableMenuProps) => { - const [ctxMenuShow] = useCreateContextMenu() - - const getAllDescendantIds = useCallback((rowId: string, data: SimpleTableRow[]): string[] => { - const descendantIds: string[] = [] - - // Find the row in the data - const findRowAndDescendants = (rows: SimpleTableRow[]): void => { - for (const row of rows) { - if (row.id === rowId) { - // Found the row, now get all its descendants - const collectDescendants = (children: SimpleTableRow[]): 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 - } - // Continue searching in subRows - if (row.subRows && row.subRows.length > 0) { - findRowAndDescendants(row.subRows) - } - } - } - - findRowAndDescendants(data) - return descendantIds - }, []) - - const handleExpand = useCallback( - (selectedIds: string[]) => { - const currentExpanded = expanded || {} - const newExpanded = { ...(currentExpanded as Record) } - - selectedIds.forEach((id) => { - // Add the folder itself - newExpanded[id] = true - - // Add ALL its descendants (children, grandchildren, etc.) - const descendantIds = getAllDescendantIds(id, tableData) - descendantIds.forEach((descendantId) => { - newExpanded[descendantId] = true - }) - }) - - setExpanded(newExpanded) - }, - [expanded, setExpanded, tableData, getAllDescendantIds], - ) - - const handleCollapse = useCallback( - (selectedIds: string[]) => { - const currentExpanded = expanded || {} - const newExpanded = { ...(currentExpanded as Record) } - - selectedIds.forEach((id) => { - // Remove the folder itself - delete newExpanded[id] - - // Remove ALL its descendants (children, grandchildren, etc.) - const descendantIds = getAllDescendantIds(id, tableData) - descendantIds.forEach((descendantId) => { - delete newExpanded[descendantId] - }) - }) - - setExpanded(newExpanded) - }, - [expanded, setExpanded, tableData, getAllDescendantIds], - ) - - // Handle alt+click for expand/collapse - const handleRowClick = useCallback( - (event: React.MouseEvent) => { - if (event.altKey) { - event.preventDefault() - event.stopPropagation() - - // Get the row ID from the event target - const rowId = event.currentTarget.id - 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] - - if (isExpanded) { - handleCollapse(idsToToggle) - } else { - handleExpand(idsToToggle) - } - } - }, - [rowSelection, expanded, handleExpand, handleCollapse], - ) - - const openContext = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - // Get the clicked row ID from the event target - const clickedRowId = e.currentTarget.id - - // Temporary selection: if clicked row is not in current selection, select it temporarily - let newSelection: RowSelectionState = { ...rowSelection } - if (!newSelection[clickedRowId]) { - newSelection = { [clickedRowId]: true } - setRowSelection(newSelection) - } - - // Get selected IDs based on the new selection - const selectedIds = Object.keys(newSelection).filter((id) => newSelection[id]) - const multipleSelected = selectedIds.length > 1 - const hasSelection = selectedIds.length > 0 - - const menuItems = [ - { - label: 'Expand All', - icon: 'unfold_more', - command: () => handleExpand(selectedIds), - hidden: multipleSelected || !hasSelection, - shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), - }, - { - label: 'Collapse All', - icon: 'unfold_less', - command: () => handleCollapse(selectedIds), - hidden: multipleSelected || !hasSelection, - shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), - }, - ].filter((item) => !item.hidden) - - ctxMenuShow(e, menuItems) - }, - [ctxMenuShow, rowSelection, setRowSelection, handleExpand, handleCollapse], - ) - - - return { - openContext, - handleRowClick, - } -} - -export default useSimpleTableMenu From ce4059cdeb5d58de2d6c59456e375f214065aaff Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Thu, 11 Dec 2025 15:08:52 +0100 Subject: [PATCH 09/12] (refactor): Remove `useSimpleTableMenu` and migrate alt-click expand/collapse logic to `SimpleTableContext` - Eliminated the `useSimpleTableMenu` hook from Slicer. - Integrated alt-click handling for expand/collapse directly into `SimpleTableContext`. - Updated `Slicer` component to use the refactored context handling via `SlicerTable`. --- src/containers/Slicer/Slicer.tsx | 36 +++++--------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/src/containers/Slicer/Slicer.tsx b/src/containers/Slicer/Slicer.tsx index 0ad4127ef..8cbece0f3 100644 --- a/src/containers/Slicer/Slicer.tsx +++ b/src/containers/Slicer/Slicer.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react' -import SimpleTable, { Container, Header, useSimpleTableContext } from '@shared/containers/SimpleTable' +import SimpleTable, { Container, Header } from '@shared/containers/SimpleTable' import useTableDataBySlice from './hooks/useTableDataBySlice' import SlicerSearch from './SlicerSearch' @@ -16,32 +16,6 @@ interface SlicerProps { persistFieldId?: SliceType // when changing slice type, leavePersistentSlice the selected field } -// Inner component that has access to SimpleTableContext -const SlicerTable: FC<{ - sliceTableData: any - isExpandable: boolean - isLoadingSliceTableData: boolean - sliceType: string - globalFilter: string -}> = ({ sliceTableData, isExpandable, isLoadingSliceTableData, sliceType, globalFilter }) => { - const { handleAltClick } = useSimpleTableContext() - - return ( - - ) -} - const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], persistFieldId }) => { const [globalFilter, setGlobalFilter] = useState('') const { @@ -110,11 +84,11 @@ const Slicer: FC = ({ sliceFields = [], entityTypes = ['task'], per menuItems:['expand-collapse'] }} > - From d815c0b8c344a58494f6a0036418314660ace924 Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Mon, 15 Dec 2025 10:52:46 +0100 Subject: [PATCH 10/12] (refactor): Simplify `SimpleTableContext` and enhance context menu logic --- .../context/SimpleTableContext.tsx | 293 +++++++++--------- 1 file changed, 146 insertions(+), 147 deletions(-) diff --git a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx index 2274ac707..e9231a534 100644 --- a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx +++ b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx @@ -1,8 +1,19 @@ -import { ExpandedState, RowPinningState, RowSelectionState, Table } from '@tanstack/react-table' -import { createContext, useContext, ReactNode, useMemo, useCallback } 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' + +// Simplified context menu item constructor for SimpleTable +export type SimpleTableContextMenuItemConstructor = ( + e: React.MouseEvent, + clickedRow: any, + selectedRows: any[], + meta: { + selectedRows: string[] + }, +) => ContextMenuItemType | ContextMenuItemType[] | undefined interface SimpleTableContextValue { // forwarded from props expanded?: ExpandedState @@ -14,7 +25,7 @@ interface SimpleTableContextValue { rowPinning?: RowPinningState onRowPinningChange?: (rowPinning: RowPinningState) => void data?: any - menuItems: (ContextMenuItemType | string)[] + menuItems: (SimpleTableContextMenuItemConstructor | string)[] onContextMenu?: (e: React.MouseEvent) => void handleAltClick?: (e: React.MouseEvent) => void } @@ -34,175 +45,155 @@ interface SimpleTableProviderProps { data?: SimpleTableContextValue['data'] menuItems?: SimpleTableContextValue['menuItems'] } -const getAllDescendantIds = (rowId: string, data: any[]): string[] => { +const getAllDescendantIds = (rowId: string, data: any): string[] => { const descendantIds: string[] = [] - const findRowAndDescendants = (rows: any[]): void => { - for (const row of rows) { - if (row.id === rowId) { - 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 - } - if (row.subRows && row.subRows.length > 0) { - findRowAndDescendants(row.subRows) + // 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) } } } - findRowAndDescendants(data) + + if (row.subRows && row.subRows.length > 0) { + collectDescendants(row.subRows) + } + return descendantIds } -export const SimpleTableProvider = ({ children, menuItems: inputMenuItems, ...props }: SimpleTableProviderProps) => { - const { expanded, setExpanded, rowSelection, setRowSelection } = props - const tableData = props.data // Assuming this is the tanstack-table data structure (sliceTableData) +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 [ctxMenuShow] = useCreateContextMenu() - - // --- Expansion/Collapse Command Handlers --- - const handleExpand = useCallback( - (selectedIds: string[]) => { - const currentExpanded = expanded || {} - const newExpanded = { ...(currentExpanded as Record) } - - selectedIds.forEach((id) => { - newExpanded[id] = true - const descendantIds = getAllDescendantIds(id, tableData) - descendantIds.forEach((descendantId) => { - newExpanded[descendantId] = true - }) - }) - - setExpanded?.(newExpanded) - }, - [expanded, setExpanded, tableData], - ) - - const handleCollapse = useCallback( - (selectedIds: string[]) => { - const currentExpanded = expanded || {} - const newExpanded = { ...(currentExpanded as Record) } - - selectedIds.forEach((id) => { - delete newExpanded[id] - const descendantIds = getAllDescendantIds(id, tableData) - descendantIds.forEach((descendantId) => { - delete newExpanded[descendantId] - }) - }) - - setExpanded?.(newExpanded) - }, - [expanded, setExpanded, tableData], - ) - - // Helper function to compute menu items based on selected IDs - const computeMenuItems = useCallback( - (selectedIds: string[]) => { - if (!Array.isArray(inputMenuItems)) return [] - - const items: ContextMenuItemType[] = [] - const hasSelection = selectedIds.length > 0 - const multipleSelected = selectedIds.length > 1 - - // Process each menu item - inputMenuItems.forEach((item) => { - if (typeof item === 'string') { - if (item === 'expand-collapse') { - const handleExpandSelected = () => handleExpand(selectedIds) - const handleCollapseSelected = () => handleCollapse(selectedIds) - - const isAllSelectedExpanded = selectedIds.every( - (id) => expanded && (expanded as Record)[id], - ) - const isAllSelectedCollapsed = selectedIds.every( - (id) => !expanded || !(expanded as Record)[id], - ) - - const expandCollapseItems: ContextMenuItemType[] = [ - { - label: 'Expand All', - icon: 'unfold_more', - command: handleExpandSelected, - hidden: isAllSelectedExpanded, - disabled: !hasSelection || multipleSelected, - shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), - }, - { - label: 'Collapse All', - icon: 'unfold_less', - command: handleCollapseSelected, - hidden: isAllSelectedCollapsed, - disabled: !hasSelection || multipleSelected, - shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), - }, - ].filter((menuItem) => !menuItem.hidden) - - if (expandCollapseItems.length > 0 && items.length > 0) { - items.push({ separator: true }) - } - items.push(...expandCollapseItems) - } + 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 { - items.push(item) + // Expand all children + newExpandedState[rowId] = true + childIds.forEach((id) => { + newExpandedState[id] = true + }) } }) - return items + setExpanded?.(newExpandedState) }, - [inputMenuItems, expanded, handleExpand, handleCollapse], + [expanded, setExpanded, tableData], ) - // --- Menu Item Construction (for display purposes) --- - const finalMenuItems: ContextMenuItemType[] = useMemo(() => { - const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) - return computeMenuItems(selectedIds) - }, [computeMenuItems, rowSelection]) + const expandCollapseChildrenItems: SimpleTableContextMenuItemConstructor = ( + e, + clickedRow, + selectedRows, + meta, + ) => { + // Check if the row has children (subRows) + const hasChildren = clickedRow.subRows && clickedRow.subRows.length > 0 + + return [ + { + label: 'Expand children', + icon: 'expand_less', + 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 the clicked row ID from the event target - const clickedRowId = e.currentTarget.id + // Get clicked row data from Map + const clickedRowData = tableData.get(rowId) + if (!clickedRowData) return - // Determine the effective selection (current or the clicked row if not selected) - let effectiveSelection = rowSelection - if (clickedRowId && !rowSelection[clickedRowId]) { - effectiveSelection = { [clickedRowId]: true } + // Get selected rows IDs and data + let selectedRowsIds = Object.keys(rowSelection).filter((id) => rowSelection[id]) - // Update the actual selection - if (setRowSelection) { - setRowSelection(effectiveSelection) - } + // If no rows are selected, use the clicked row + if (selectedRowsIds.length === 0) { + selectedRowsIds = [rowId] } - // Compute menu items based on effective selection - const effectiveSelectedIds = Object.keys(effectiveSelection).filter((id) => effectiveSelection[id]) - - const menuItemsToShow = computeMenuItems(effectiveSelectedIds) + 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(), + ] + + const constructedMenuItems = inputMenuItems?.flatMap((constructor) => + typeof constructor === 'function' + ? constructor(e, clickedRowData, filteredSelectedRowsData, { + selectedRows: selectedRowsIds, + }) + : constructor in builtInMenuItems + ? builtInMenuItems[constructor as DefaultMenuItem]( + e, + clickedRowData, + filteredSelectedRowsData, + { + selectedRows: selectedRowsIds, + }, + ) + : [], + ) - // Show context menu - if (menuItemsToShow.length > 0) { - ctxMenuShow(e, menuItemsToShow) - } + cellContextMenuShow(e, constructedMenuItems) }, - [ctxMenuShow, rowSelection, setRowSelection, computeMenuItems], + [inputMenuItems, rowSelection, builtInMenuItems, cellContextMenuShow, tableData], ) - // Alt+Click handler for expand/collapse const handleAltClick = useCallback( (e: React.MouseEvent) => { if (e.altKey) { @@ -223,17 +214,25 @@ export const SimpleTableProvider = ({ children, menuItems: inputMenuItems, ...pr // Check if the clicked row is expanded const isExpanded = expanded && (expanded as Record)[rowId] - if (isExpanded) { - handleCollapse(idsToToggle) - } else { - handleExpand(idsToToggle) - } + toggleExpandAll(idsToToggle, !isExpanded) } }, - [rowSelection, expanded, handleExpand, handleCollapse], + [rowSelection, expanded, toggleExpandAll], ) - return {children} + return ( + + {children} + + ) } export const useSimpleTableContext = () => { From d5521f95226fed45a456008e9075f71bfe70f5f6 Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Tue, 3 Feb 2026 14:42:41 +0100 Subject: [PATCH 11/12] (fix): Add Alt+Click functionality for expand/collapse in `SimpleTable` and `ProjectTreeTable` - Introduced Alt+Click handler to simplify expand/collapse actions. - Enhanced metadata handling for context menu logic with pre-computed row information. - Updated `SimpleTable` and `ProjectTreeTable` to utilize the new functionality. --- .../ProjectTreeTable/ProjectTreeTable.tsx | 2 + .../hooks/useCellContextMenu.ts | 43 +++++++- .../containers/SimpleTable/SimpleTable.tsx | 7 +- .../context/SimpleTableContext.tsx | 97 ++++++++++++++----- 4 files changed, 124 insertions(+), 25 deletions(-) diff --git a/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx b/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx index 784dfb39b..056e670c3 100644 --- a/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx +++ b/shared/src/containers/ProjectTreeTable/ProjectTreeTable.tsx @@ -991,6 +991,7 @@ const TableBody = ({ }) const handleTableBodyContextMenu = cellContextMenuHook.handleTableBodyContextMenu + const handleTableBodyAltClick = cellContextMenuHook.handleTableBodyAltClick const { handlePreFetchTasks } = usePrefetchFolderTasks() @@ -1034,6 +1035,7 @@ const TableBody = ({ display: 'grid', }} onContextMenu={handleTableBodyContextMenu} + onClick={handleTableBodyAltClick} onMouseOver={(e) => { handlePreFetchTasks(e) }} diff --git a/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts b/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts index f5e042edf..a0be0f361 100644 --- a/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts +++ b/shared/src/containers/ProjectTreeTable/hooks/useCellContextMenu.ts @@ -442,7 +442,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/SimpleTable/SimpleTable.tsx b/shared/src/containers/SimpleTable/SimpleTable.tsx index 1c2f82979..288ae6903 100644 --- a/shared/src/containers/SimpleTable/SimpleTable.tsx +++ b/shared/src/containers/SimpleTable/SimpleTable.tsx @@ -211,7 +211,8 @@ const SimpleTable: FC = ({ onRowSelectionChange, rowPinning, onRowPinningChange, - onContextMenu + onContextMenu, + handleAltClick, } = useSimpleTableContext() const lastSelectedIdRef = useRef(null) const tableRef = useRef | null>(null) @@ -375,6 +376,9 @@ const SimpleTable: FC = ({ const cellMeta = cellTableInstance.options.meta const handleCellClick = (event: ReactMouseEvent) => { + // Alt+Click is handled by handleAltClick on the row for expand/collapse + if (event.altKey) return + // Prevent row selection if clicking on an interactive element within the cell if ( event.target instanceof HTMLInputElement || @@ -594,6 +598,7 @@ const SimpleTable: FC = ({ id={row.id} {...pt?.row} onContextMenu={onContextMenu} + onClick={handleAltClick} 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/context/SimpleTableContext.tsx b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx index e9231a534..57be2bb1e 100644 --- a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx +++ b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx @@ -5,14 +5,39 @@ import { getPlatformShortcutKey, KeyMode } from '@shared/util' type DefaultMenuItem = 'expand-collapse' -// Simplified context menu item constructor for SimpleTable +// 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: { - selectedRows: string[] - }, + meta: SimpleTableContextMeta, ) => ContextMenuItemType | ContextMenuItemType[] | undefined interface SimpleTableContextValue { // forwarded from props @@ -68,6 +93,20 @@ const getAllDescendantIds = (rowId: string, data: any): string[] => { return descendantIds } + +// 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, @@ -110,18 +149,18 @@ export const SimpleTableProvider = ({ ) const expandCollapseChildrenItems: SimpleTableContextMenuItemConstructor = ( - e, - clickedRow, - selectedRows, + _e, + _clickedRow, + _selectedRows, meta, ) => { - // Check if the row has children (subRows) - const hasChildren = clickedRow.subRows && clickedRow.subRows.length > 0 + // Use pre-computed metadata + const { hasChildren } = meta.clickedRowMeta return [ { label: 'Expand children', - icon: 'expand_less', + icon: 'expand_all', command: () => toggleExpandAll(meta.selectedRows, true), hidden: !hasChildren, shortcut: getPlatformShortcutKey('click', [KeyMode.Alt]), @@ -172,26 +211,38 @@ export const SimpleTableProvider = ({ ...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, { - selectedRows: selectedRowsIds, - }) + ? constructor(e, clickedRowData, filteredSelectedRowsData, meta) : constructor in builtInMenuItems - ? builtInMenuItems[constructor as DefaultMenuItem]( - e, - clickedRowData, - filteredSelectedRowsData, - { - selectedRows: selectedRowsIds, - }, - ) - : [], + ? builtInMenuItems[constructor as DefaultMenuItem]( + e, + clickedRowData, + filteredSelectedRowsData, + meta, + ) + : [], ) cellContextMenuShow(e, constructedMenuItems) }, - [inputMenuItems, rowSelection, builtInMenuItems, cellContextMenuShow, tableData], + [inputMenuItems, rowSelection, builtInMenuItems, cellContextMenuShow, tableData, expanded], ) const handleAltClick = useCallback( From 3435832ee56e928228ec8e8a7b37b7e21b48d2c4 Mon Sep 17 00:00:00 2001 From: filipvnencak Date: Fri, 6 Mar 2026 10:05:44 +0100 Subject: [PATCH 12/12] feat(SimpleTable): replace `handleAltClick` with `expandAllForRow` for improved row expansion handling and context consistency --- .../widgets/EntityNameWidget.tsx | 1 + .../containers/SimpleTable/SimpleTable.tsx | 7 ++-- .../SimpleTable/SimpleTableRowTemplate.tsx | 11 ++++++- .../context/SimpleTableContext.tsx | 33 ++++++++----------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/shared/src/containers/ProjectTreeTable/widgets/EntityNameWidget.tsx b/shared/src/containers/ProjectTreeTable/widgets/EntityNameWidget.tsx index 3d292676b..49bad8f39 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 288ae6903..406b657b8 100644 --- a/shared/src/containers/SimpleTable/SimpleTable.tsx +++ b/shared/src/containers/SimpleTable/SimpleTable.tsx @@ -212,7 +212,7 @@ const SimpleTable: FC = ({ rowPinning, onRowPinningChange, onContextMenu, - handleAltClick, + expandAllForRow, } = useSimpleTableContext() const lastSelectedIdRef = useRef(null) const tableRef = useRef | null>(null) @@ -376,7 +376,7 @@ const SimpleTable: FC = ({ const cellMeta = cellTableInstance.options.meta const handleCellClick = (event: ReactMouseEvent) => { - // Alt+Click is handled by handleAltClick on the row for expand/collapse + // 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 @@ -427,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, @@ -451,6 +452,7 @@ const SimpleTable: FC = ({ enableClickToDeselect, enableNonFolderIndent, imgRatio, + expandAllForRow, ], // include enableClickToDeselect for completeness ) @@ -598,7 +600,6 @@ const SimpleTable: FC = ({ id={row.id} {...pt?.row} onContextMenu={onContextMenu} - onClick={handleAltClick} 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 c6d3f2b85..f2bce8783 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 57be2bb1e..5a0a8e4bb 100644 --- a/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx +++ b/shared/src/containers/SimpleTable/context/SimpleTableContext.tsx @@ -52,7 +52,7 @@ interface SimpleTableContextValue { data?: any menuItems: (SimpleTableContextMenuItemConstructor | string)[] onContextMenu?: (e: React.MouseEvent) => void - handleAltClick?: (e: React.MouseEvent) => void + expandAllForRow?: (rowId: string) => void } const SimpleTableContext = createContext(undefined) @@ -245,28 +245,21 @@ export const SimpleTableProvider = ({ [inputMenuItems, rowSelection, builtInMenuItems, cellContextMenuShow, tableData, expanded], ) - const handleAltClick = useCallback( - (e: React.MouseEvent) => { - if (e.altKey) { - e.preventDefault() - e.stopPropagation() - - // Get the row ID from the event target - const rowId = e.currentTarget.id - if (!rowId) return + 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) + // 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] + // 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] + // Check if the clicked row is expanded + const isExpanded = expanded && (expanded as Record)[rowId] - toggleExpandAll(idsToToggle, !isExpanded) - } + toggleExpandAll(idsToToggle, !isExpanded) }, [rowSelection, expanded, toggleExpandAll], ) @@ -278,7 +271,7 @@ export const SimpleTableProvider = ({ rowSelection, menuItems: inputMenuItems || [], onContextMenu, - handleAltClick, + expandAllForRow, }} > {children}