From eea555b7438e3f5bd5ac0d125fdee3f7f9437473 Mon Sep 17 00:00:00 2001 From: Neel Gondalia Date: Fri, 1 Dec 2023 16:59:05 -0500 Subject: [PATCH] Add support for multi row select and context menu in Timegraph Component changes made: - Support for adding context menu via signals - Support for making multiple selections in data tree of timegraph component - Added Signals to notify context menu selection and multi row selections This change will allow for a context menu to be added to the timegraph component based on the outputDescriptor id via a signal. The component also adds the ability to select multiple rows. The multi selections can be passed with the item clicked signal payload as well as through a rowSelectionsChanged signal. The end goal is to provide the timegraph views with the ability to make multi-row selections and perform some actions with the selections. Signed-off-by: Neel Gondalia ngondalia@blackberry.com --- ...ontext-menu-contributed-signal-payload.tsx | 41 ++++ ...ntext-menu-item-clicked-signal-payload.tsx | 29 +++ packages/base/src/signals/signal-manager.ts | 19 +- .../components/timegraph-output-component.tsx | 195 +++++++++++++++++- .../utils/filter-tree/entry-tree.tsx | 5 +- .../utils/filter-tree/table-body.tsx | 2 + .../utils/filter-tree/table-row.tsx | 44 ++-- .../components/utils/filter-tree/table.tsx | 2 + .../src/components/utils/filter-tree/tree.tsx | 4 + .../time-graph-navigation-shortcuts-table.tsx | 10 + .../style/output-components-style.css | 4 + .../style/react-contextify.css | 4 +- 12 files changed, 333 insertions(+), 26 deletions(-) create mode 100644 packages/base/src/signals/context-menu-contributed-signal-payload.tsx create mode 100644 packages/base/src/signals/context-menu-item-clicked-signal-payload.tsx diff --git a/packages/base/src/signals/context-menu-contributed-signal-payload.tsx b/packages/base/src/signals/context-menu-contributed-signal-payload.tsx new file mode 100644 index 000000000..2b7b12c6c --- /dev/null +++ b/packages/base/src/signals/context-menu-contributed-signal-payload.tsx @@ -0,0 +1,41 @@ +/*************************************************************************************** + * Copyright (c) 2023 BlackBerry Limited and contributors. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + ***************************************************************************************/ +import { OutputDescriptor } from 'tsp-typescript-client/lib/models/output-descriptor'; + +export interface MenuItem { + id: string; + label: string; +} + +export interface SubMenu { + label: string; + items: MenuItem[]; + submenu: SubMenu | undefined; +} + +export interface ContextMenu { + menuId: string, + submenus: SubMenu[]; + items: MenuItem[]; +} + +export class ContextMenuContributedSignalPayload { + private outputDescriptor: OutputDescriptor; + private menu: ContextMenu; + + constructor(outputDescriptor: OutputDescriptor, menuItems: ContextMenu) { + this.outputDescriptor = outputDescriptor; + this.menu = menuItems; + } + + public getOutputDescriptor(): OutputDescriptor { + return this.outputDescriptor; + } + + public getMenu(): ContextMenu { + return this.menu; + } +} diff --git a/packages/base/src/signals/context-menu-item-clicked-signal-payload.tsx b/packages/base/src/signals/context-menu-item-clicked-signal-payload.tsx new file mode 100644 index 000000000..68e64a801 --- /dev/null +++ b/packages/base/src/signals/context-menu-item-clicked-signal-payload.tsx @@ -0,0 +1,29 @@ +/*************************************************************************************** + * Copyright (c) 2023 BlackBerry Limited and contributors. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + ***************************************************************************************/ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export class ContextMenuItemClickedSignalPayload { + private itemId: string; + private menuId: string; + private props: { [key: string]: any }; + + constructor(itemId: string, menuId: string, props: { [key: string]: any }) { + this.itemId = itemId; + this.menuId = menuId; + this.props = props; + } + + public getMenuId(): string { + return this.menuId; + } + + public getItemId(): string { + return this.itemId; + } + + public getProps(): { [key: string]: any } { + return this.props; + } +} diff --git a/packages/base/src/signals/signal-manager.ts b/packages/base/src/signals/signal-manager.ts index 2cd3da831..6ed63f75a 100644 --- a/packages/base/src/signals/signal-manager.ts +++ b/packages/base/src/signals/signal-manager.ts @@ -5,6 +5,8 @@ import { Trace } from 'tsp-typescript-client/lib/models/trace'; import { OpenedTracesUpdatedSignalPayload } from './opened-traces-updated-signal-payload'; import { OutputAddedSignalPayload } from './output-added-signal-payload'; import { TimeRangeUpdatePayload } from './time-range-data-signal-payloads'; +import { ContextMenuContributedSignalPayload } from './context-menu-contributed-signal-payload'; +import { ContextMenuItemClickedSignalPayload } from './context-menu-item-clicked-signal-payload'; export declare interface SignalManager { fireTraceOpenedSignal(trace: Trace): void; @@ -20,6 +22,7 @@ export declare interface SignalManager { fireThemeChangedSignal(theme: string): void; // TODO - Refactor or remove this signal. Similar signal to fireRequestSelectionRangeChange fireSelectionChangedSignal(payload: { [key: string]: string }): void; + fireRowSelectionsChanged(payload: { traceId: string; outputDescriptor: OutputDescriptor; rowIds: number[] }): void; fireCloseTraceViewerTabSignal(traceUUID: string): void; fireTraceViewerTabActivatedSignal(experiment: Experiment): void; fireUpdateZoomSignal(hasZoomedIn: boolean): void; @@ -41,6 +44,8 @@ export declare interface SignalManager { fireSelectionRangeUpdated(payload: TimeRangeUpdatePayload): void; fireViewRangeUpdated(payload: TimeRangeUpdatePayload): void; fireRequestSelectionRangeChange(payload: TimeRangeUpdatePayload): void; + fireContributeContextMenu(payload: ContextMenuContributedSignalPayload): void; + fireContextMenuItemClicked(payload: ContextMenuItemClickedSignalPayload): void; } export const Signals = { @@ -57,6 +62,7 @@ export const Signals = { ITEM_PROPERTIES_UPDATED: 'item properties updated', THEME_CHANGED: 'theme changed', SELECTION_CHANGED: 'selection changed', + ROW_SELECTIONS_CHANGED: 'rows selected changed', CLOSE_TRACEVIEWERTAB: 'tab closed', TRACEVIEWERTAB_ACTIVATED: 'widget activated', UPDATE_ZOOM: 'update zoom', @@ -75,7 +81,9 @@ export const Signals = { VIEW_RANGE_UPDATED: 'view range updated', SELECTION_RANGE_UPDATED: 'selection range updated', REQUEST_SELECTION_RANGE_CHANGE: 'change selection range', - OUTPUT_DATA_CHANGED: 'output data changed' + OUTPUT_DATA_CHANGED: 'output data changed', + CONTRIBUTE_CONTEXT_MENU: 'contribute context menu', + CONTEXT_MENU_ITEM_CLICKED: 'context menu item clicked', }; export class SignalManager extends EventEmitter implements SignalManager { @@ -97,6 +105,9 @@ export class SignalManager extends EventEmitter implements SignalManager { fireExperimentSelectedSignal(experiment: Experiment | undefined): void { this.emit(Signals.EXPERIMENT_SELECTED, experiment); } + fireRowSelectionsChanged(payload: { traceId: string; outputDescriptor: OutputDescriptor; rowIds: number[] }): void { + this.emit(Signals.ROW_SELECTIONS_CHANGED, payload); + } fireExperimentUpdatedSignal(experiment: Experiment): void { this.emit(Signals.EXPERIMENT_UPDATED, experiment); } @@ -174,6 +185,12 @@ export class SignalManager extends EventEmitter implements SignalManager { fireRequestSelectionRangeChange(payload: TimeRangeUpdatePayload): void { this.emit(Signals.REQUEST_SELECTION_RANGE_CHANGE, payload); } + fireContributeContextMenu(payload: ContextMenuContributedSignalPayload): void { + this.emit(Signals.CONTRIBUTE_CONTEXT_MENU, payload); + } + fireContextMenuItemClicked(payload: ContextMenuItemClickedSignalPayload): void { + this.emit(Signals.CONTEXT_MENU_ITEM_CLICKED, payload); + } } let instance: SignalManager = new SignalManager(); diff --git a/packages/react-components/src/components/timegraph-output-component.tsx b/packages/react-components/src/components/timegraph-output-component.tsx index 4e2f5a59b..581e45967 100644 --- a/packages/react-components/src/components/timegraph-output-component.tsx +++ b/packages/react-components/src/components/timegraph-output-component.tsx @@ -35,6 +35,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import TextField from '@mui/material/TextField'; import InputAdornment from '@mui/material/InputAdornment'; import { debounce } from 'lodash'; +import '../../style/react-contextify.css'; +import { Item, ItemParams, Menu, Separator, Submenu, useContextMenu } from 'react-contexify'; +import { ContextMenuContributedSignalPayload, ContextMenu, MenuItem, SubMenu } from 'traceviewer-base/lib/signals/context-menu-contributed-signal-payload'; +import { ContextMenuItemClickedSignalPayload } from 'traceviewer-base/lib/signals/context-menu-item-clicked-signal-payload'; type TimegraphOutputProps = AbstractOutputProps & { addWidgetResizeHandler: (handler: () => void) => void; @@ -45,19 +49,21 @@ type TimegraphOutputState = AbstractTreeOutputState & { timegraphTree: TimeGraphEntry[]; markerCategoryEntries: Entry[]; markerLayerData: - | { rows: TimelineChart.TimeGraphRowModel[]; range: TimelineChart.TimeGraphRange; resolution: number } - | undefined; + | { rows: TimelineChart.TimeGraphRowModel[]; range: TimelineChart.TimeGraphRange; resolution: number } + | undefined; selectedRow?: number; + selectedRows?: number[]; selectedMarkerRow?: number; collapsedNodes: number[]; collapsedMarkerNodes: number[]; columns: ColumnHeader[]; dataRows: TimelineChart.TimeGraphRowModel[]; searchString: string; + ctxMenu?: ContextMenu; }; const COARSE_RESOLUTION_FACTOR = 8; // resolution factor to use for first (coarse) update - +const MENU_ID = 'timegraph.context.menuId '; export class TimegraphOutputComponent extends AbstractTreeOutputComponent { private totalHeight = 0; private rowController: TimeGraphRowController; @@ -83,6 +89,7 @@ export class TimegraphOutputComponent extends AbstractTreeOutputComponent this.doHandleSelectionChangedSignal(payload); private onOutputDataChanged = (outputs: OutputDescriptor[]) => this.doHandleOutputDataChangedSignal(outputs); + private onContextMenuContributed = (payload: ContextMenuContributedSignalPayload) => this.doHandleContextMenuContributed(payload); private pendingSelection: TimeGraphEntry | undefined; private _debouncedUpdateSearch = debounce(() => this.updateSearchFilter(), 500); @@ -101,6 +108,7 @@ export class TimegraphOutputComponent extends AbstractTreeOutputComponent this.getTimegraphRowIds(), @@ -252,12 +262,14 @@ export class TimegraphOutputComponent extends AbstractTreeOutputComponent + {this.renderContextMenu()} @@ -521,6 +537,97 @@ export class TimegraphOutputComponent extends AbstractTreeOutputComponent): void { + event.preventDefault(); + event.stopPropagation(); + const refNode = this.treeRef.current; + let calculatedX = 0; + let calculatedY = 0; + if (refNode) { + calculatedX = event.clientX; + calculatedY = event.clientY - refNode.getBoundingClientRect().top; + } + + const { show } = useContextMenu({ + id: MENU_ID + this.props.outputDescriptor.id + }); + + show(event, { + props: {}, + position: { x: calculatedX, y: calculatedY } + }); + } + + protected handleItemClick = (params: ItemParams): void => { + const props = { + outputDescriptor: this.props.outputDescriptor, + selectedRow: this.state.selectedRow, + selectedRows: this.state.selectedRows + }; + const signalPayload: ContextMenuItemClickedSignalPayload = new ContextMenuItemClickedSignalPayload(params.data.itemId, params.data.menuId, props); + signalManager().fireContextMenuItemClicked(signalPayload); + }; + + renderContextMenu(): React.ReactNode { + return ( + + {this.state.ctxMenu ? ( + + {this.renderSubMenu(this.state.ctxMenu.submenus)} + {this.state.ctxMenu.submenus.length > 0 && } + {this.renderItems(this.state.ctxMenu.items)} + + ) : + <> + } + + ); + } + renderSubMenu(submenus: SubMenu[]): React.ReactNode { + return ( + + {submenus && submenus.length > 0 ? ( + submenus.map(menu => ( + + {menu.submenu && this.renderSubMenu([menu.submenu])} + {this.renderItems(menu.items)} + + )) + ) : ( + <> + )} + + ); + } + + renderItems(items: MenuItem[]): React.ReactNode { + return ( + + {items && items.length > 0 ? ( + items.map(item => ( + {item.label} + )) + ) : ( + <> + )} + + ); + } + renderYAxis(): React.ReactNode { return undefined; } @@ -977,6 +1084,12 @@ export class TimegraphOutputComponent extends AbstractTreeOutputComponent { // Simulate a click on the selected row when theme changes. // This changes the color of the selected row to new theme. @@ -1197,6 +1310,68 @@ export class TimegraphOutputComponent extends AbstractTreeOutputComponent { + // Hoooked to a context menu - if no context menu provided, multi-selection will route to onRowClick + if (!this.state.ctxMenu) { + this.onRowClick(id); + return; + } + const tree = listToTree(this.state.timegraphTree, this.state.columns); + const rowIndex = getIndexOfNode( + id, + tree, + this.state.collapsedNodes + ); + this.chartLayer.selectAndReveal(rowIndex); + + const rows = this.state.selectedRows ? this.state.selectedRows : []; + const lastSelectedRow = this.state.selectedRows?.at(this.state.selectedRows.length - 1); + let lastSelectedRowIndex = undefined; + if (lastSelectedRow) { + lastSelectedRowIndex = getIndexOfNode( + lastSelectedRow, + tree, + this.state.collapsedNodes + ); + } + if (isShiftClicked && lastSelectedRowIndex) { + let startIndex = 0; + let endIndex = 0; + if (lastSelectedRowIndex < rowIndex) { + startIndex = lastSelectedRowIndex; + endIndex = rowIndex; + } else { + startIndex = rowIndex; + endIndex = lastSelectedRowIndex; + } + + const nodeIds = getAllExpandedNodeIds(tree, this.state.collapsedNodes); + if (startIndex < 0 || startIndex >= nodeIds.length || endIndex >= nodeIds.length) { + return; + } + for (let i = startIndex; i <= endIndex; i++) { + const nodeId = nodeIds.at(i); + if (nodeId) { + rows.push(nodeId); + } + } + } else { + if (rows.includes(id)) { + const index = rows.indexOf(id); + rows.splice(index, 1); + } else { + rows?.push(id); + } + } + this.setState({ selectedRow: undefined, selectedRows: [...rows] }); + signalManager().fireRowSelectionsChanged({ traceId: this.props.traceId, outputDescriptor: this.props.outputDescriptor, rowIds: [...rows] }); }; public onMarkerRowClick = (id: number): void => { @@ -1208,9 +1383,13 @@ export class TimegraphOutputComponent extends AbstractTreeOutputComponent this.setState({ selectedRow: row.id }); - public onMarkerSelectionChange = (row: TimelineChart.TimeGraphRowModel): void => + public onSelectionChange = (row: TimelineChart.TimeGraphRowModel): void => { + this.setState({ selectedRow: row.id }); + }; + + public onMarkerSelectionChange = (row: TimelineChart.TimeGraphRowModel): void => { this.setState({ selectedMarkerRow: row.id }); + }; private selectAndReveal(item: TimeGraphEntry) { const rowIndex = getIndexOfNode( diff --git a/packages/react-components/src/components/utils/filter-tree/entry-tree.tsx b/packages/react-components/src/components/utils/filter-tree/entry-tree.tsx index 9093ff114..d39237752 100644 --- a/packages/react-components/src/components/utils/filter-tree/entry-tree.tsx +++ b/packages/react-components/src/components/utils/filter-tree/entry-tree.tsx @@ -11,10 +11,12 @@ interface EntryTreeProps { showCheckboxes: boolean; showCloseIcons: boolean; selectedRow?: number; + selectedRows?: number[]; collapsedNodes: number[]; showFilter: boolean; onToggleCheck: (ids: number[]) => void; onRowClick: (id: number) => void; + onMultipleRowClick?: (id: number, isShiftClicked?: boolean) => void; onContextMenu: (event: React.MouseEvent, id: number) => void; onClose: (id: number) => void; onToggleCollapse: (id: number, nodes: TreeNode[]) => void; @@ -43,7 +45,8 @@ export class EntryTree extends React.Component { this.props.checkedSeries !== nextProps.checkedSeries || this.props.entries !== nextProps.entries || this.props.collapsedNodes !== nextProps.collapsedNodes || - this.props.selectedRow !== nextProps.selectedRow; + this.props.selectedRow !== nextProps.selectedRow || + this.props.selectedRows !== nextProps.selectedRows; render(): JSX.Element { return ; diff --git a/packages/react-components/src/components/utils/filter-tree/table-body.tsx b/packages/react-components/src/components/utils/filter-tree/table-body.tsx index 2192f1796..347315dd7 100644 --- a/packages/react-components/src/components/utils/filter-tree/table-body.tsx +++ b/packages/react-components/src/components/utils/filter-tree/table-body.tsx @@ -5,12 +5,14 @@ import { TableRow } from './table-row'; interface TableBodyProps { nodes: TreeNode[]; selectedNode?: number; + selectedNodes?: number[]; collapsedNodes: number[]; isCheckable: boolean; isClosable: boolean; getCheckedStatus: (id: number) => number; onToggleCollapse: (id: number) => void; onRowClick: (id: number) => void; + onMultipleRowClick?: (id: number, isShiftClicked?: boolean) => void; onClose: (id: number) => void; onToggleCheck: (id: number) => void; onContextMenu: (event: React.MouseEvent, id: number) => void; diff --git a/packages/react-components/src/components/utils/filter-tree/table-row.tsx b/packages/react-components/src/components/utils/filter-tree/table-row.tsx index 624490dff..4cd7ddde8 100644 --- a/packages/react-components/src/components/utils/filter-tree/table-row.tsx +++ b/packages/react-components/src/components/utils/filter-tree/table-row.tsx @@ -8,6 +8,7 @@ interface TableRowProps { node: TreeNode; level: number; selectedRow?: number; + selectedRows?: number[]; collapsedNodes: number[]; isCheckable: boolean; isClosable: boolean; @@ -16,6 +17,7 @@ interface TableRowProps { onClose: (id: number) => void; onToggleCheck: (id: number) => void; onRowClick: (id: number) => void; + onMultipleRowClick?: (id: number, isShiftClicked?: boolean) => void; onContextMenu: (event: React.MouseEvent, id: number) => void; } @@ -89,9 +91,14 @@ export class TableRow extends React.Component { return undefined; }; - onClick = (): void => { - const { node, onRowClick } = this.props; - if (onRowClick) { + onClick = (e: React.MouseEvent): void => { + const { node, onRowClick, onMultipleRowClick } = this.props; + + if (onMultipleRowClick && e.ctrlKey) { + onMultipleRowClick(node.id, false); + } else if (onMultipleRowClick && e.shiftKey) { + onMultipleRowClick(node.id, true); + } else { onRowClick(node.id); } }; @@ -108,16 +115,25 @@ export class TableRow extends React.Component { return undefined; } const children = this.renderChildren(); - const { node, selectedRow } = this.props; - const className = selectedRow === node.id ? 'selected' : ''; - - return ( - - - {this.renderRow()} - - {children} - - ); + const { node, selectedRow, selectedRows } = this.props; + let className = ''; + + if (selectedRows && selectedRows.length > 1 && selectedRows.includes(node.id)) { + className = 'multiselected'; + } + if (selectedRow === node.id) { + className = 'selected'; + } + + { + return ( + + + {this.renderRow()} + + {children} + + ); + } } } diff --git a/packages/react-components/src/components/utils/filter-tree/table.tsx b/packages/react-components/src/components/utils/filter-tree/table.tsx index 436cca4f9..6920bc1ab 100644 --- a/packages/react-components/src/components/utils/filter-tree/table.tsx +++ b/packages/react-components/src/components/utils/filter-tree/table.tsx @@ -8,11 +8,13 @@ import ColumnHeader from './column-header'; interface TableProps { nodes: TreeNode[]; selectedRow?: number; + selectedRows?: number[]; collapsedNodes: number[]; isCheckable: boolean; isClosable: boolean; sortConfig: SortConfig[]; onRowClick: (id: number) => void; + onMultipleRowClick?: (id: number, isShiftClicked?: boolean) => void; onContextMenu: (event: React.MouseEvent, id: number) => void; getCheckedStatus: (id: number) => number; onToggleCollapse: (id: number) => void; diff --git a/packages/react-components/src/components/utils/filter-tree/tree.tsx b/packages/react-components/src/components/utils/filter-tree/tree.tsx index a1f5bd6a4..cb7526f97 100644 --- a/packages/react-components/src/components/utils/filter-tree/tree.tsx +++ b/packages/react-components/src/components/utils/filter-tree/tree.tsx @@ -16,9 +16,11 @@ interface FilterTreeProps { checkedSeries: number[]; // Optional collapsedNodes: number[]; selectedRow?: number; + selectedRows?: number[]; onToggleCheck: (ids: number[]) => void; // Optional onClose: (id: number) => void; onRowClick: (id: number) => void; + onMultipleRowClick?: (id: number, isShiftClicked?: boolean) => void; onContextMenu: (event: React.MouseEvent, id: number) => void; onToggleCollapse: (id: number, nodes: TreeNode[]) => void; onOrderChange: (ids: number[]) => void; @@ -269,6 +271,7 @@ export class FilterTree extends React.Component, + + Multi-Select + + Ctrl + Click + or + Shift + Click + + diff --git a/packages/react-components/style/output-components-style.css b/packages/react-components/style/output-components-style.css index fa7587325..e917d1d70 100644 --- a/packages/react-components/style/output-components-style.css +++ b/packages/react-components/style/output-components-style.css @@ -239,6 +239,10 @@ canvas { background-color: var(--trace-viewer-selection-background) !important; } +.table-tree tr.multiselected td { + background-color: var(--trace-viewer-list-hoverBackground) !important; +} + .resize-handle { float: right; position: absolute; diff --git a/packages/react-components/style/react-contextify.css b/packages/react-components/style/react-contextify.css index aa7ba9d16..566e9a3a7 100644 --- a/packages/react-components/style/react-contextify.css +++ b/packages/react-components/style/react-contextify.css @@ -10,7 +10,7 @@ box-shadow: 0px 10px 30px -5px rgba(0, 0, 0, 0.3); border-radius: 6px; padding: 6px 0; - min-width: 200px; + min-width: 150px; z-index: 100; } .react-contexify__submenu--is-open, .react-contexify__submenu--is-open > .react-contexify__item__content { @@ -231,4 +231,4 @@ animation: react-contexify__slideOut 0.3s; } - /*# sourceMappingURL=ReactContexify.css.map */ \ No newline at end of file + /*# sourceMappingURL=ReactContexify.css.map */