diff --git a/src/Explorer/Tables/DataTable/DataTableOperationManager.ts b/src/Explorer/Tables/DataTable/DataTableOperationManager.ts index 47d202650..67ba1c6a7 100644 --- a/src/Explorer/Tables/DataTable/DataTableOperationManager.ts +++ b/src/Explorer/Tables/DataTable/DataTableOperationManager.ts @@ -1,5 +1,6 @@ import ko from "knockout"; +import { isEnvironmentAltPressed, isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import * as Constants from "../Constants"; import * as Entities from "../Entities"; import * as Utilities from "../Utilities"; @@ -28,7 +29,7 @@ export default class DataTableOperationManager { var elem: JQuery = $(event.currentTarget); this.updateLastSelectedItem(elem, event.shiftKey); - if (Utilities.isEnvironmentCtrlPressed(event)) { + if (isEnvironmentCtrlPressed(event)) { this.applyCtrlSelection(elem); } else if (event.shiftKey) { this.applyShiftSelection(elem); @@ -74,9 +75,9 @@ export default class DataTableOperationManager { DataTableOperations.scrollToRowIfNeeded(dataTableRows, safeIndex, isUpArrowKey); } } else if ( - Utilities.isEnvironmentCtrlPressed(event) && - !Utilities.isEnvironmentShiftPressed(event) && - !Utilities.isEnvironmentAltPressed(event) && + isEnvironmentCtrlPressed(event) && + !isEnvironmentShiftPressed(event) && + !isEnvironmentAltPressed(event) && event.keyCode === Constants.keyCodes.A ) { this.applySelectAll(); diff --git a/src/Explorer/Tables/Utilities.ts b/src/Explorer/Tables/Utilities.ts index 4e3d11bc0..8d5ab453c 100644 --- a/src/Explorer/Tables/Utilities.ts +++ b/src/Explorer/Tables/Utilities.ts @@ -1,8 +1,8 @@ -import * as _ from "underscore"; import Q from "q"; +import * as _ from "underscore"; +import * as Constants from "./Constants"; import * as Entities from "./Entities"; import { CassandraTableKey } from "./TableDataClient"; -import * as Constants from "./Constants"; /** * Generates a pseudo-random GUID. @@ -180,30 +180,6 @@ export function onEsc( return onKey(event, Constants.keyCodes.Esc, action, metaKey, shiftKey, altKey); } -/** - * Is the environment 'ctrl' key press. This key is used for multi selection, like select one more item, select all. - * For Windows and Linux, it's ctrl. For Mac, it's command. - */ -export function isEnvironmentCtrlPressed(event: JQueryEventObject): boolean { - return isMac() ? event.metaKey : event.ctrlKey; -} - -export function isEnvironmentShiftPressed(event: JQueryEventObject): boolean { - return event.shiftKey; -} - -export function isEnvironmentAltPressed(event: JQueryEventObject): boolean { - return event.altKey; -} - -/** - * Returns whether the current platform is MacOS. - */ -export function isMac(): boolean { - var platform = navigator.platform.toUpperCase(); - return platform.indexOf("MAC") >= 0; -} - // MAX_SAFE_INTEGER and MIN_SAFE_INTEGER will be provided by ECMAScript 6's Number export var MAX_SAFE_INTEGER = Math.pow(2, 53) - 1; export var MIN_SAFE_INTEGER = -MAX_SAFE_INTEGER; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx index d66568875..58f486c3b 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -22,6 +22,9 @@ import { useTableFeatures, useTableSelection, } from "@fluentui/react-components"; +import { NormalizedEventKey } from "Common/Constants"; +import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; +import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import React, { useCallback, useEffect, useMemo } from "react"; import { FixedSizeList as List, ListChildComponentProps } from "react-window"; @@ -123,17 +126,63 @@ export const DocumentsTableComponent: React.FC = [columnHeaders], ); - const onIdClicked = useCallback((index: number) => onSelectedRowsChange(new Set([index])), [onSelectedRowsChange]); + const [selectionStartIndex, setSelectionStartIndex] = React.useState(undefined); + const onTableCellClicked = useCallback( + (e: React.MouseEvent, index: number) => { + if (isSelectionDisabled) { + // Only allow click + onSelectedRowsChange(new Set([index])); + setSelectionStartIndex(index); + return; + } + + const result = selectionHelper( + selectedRows as Set, + index, + isEnvironmentShiftPressed(e), + isEnvironmentCtrlPressed(e), + selectionStartIndex, + ); + onSelectedRowsChange(result.selection); + if (result.selectionStartIndex !== undefined) { + setSelectionStartIndex(result.selectionStartIndex); + } + }, + [isSelectionDisabled, selectedRows, selectionStartIndex, onSelectedRowsChange], + ); + + /** + * Callback for when: + * - a key has been pressed on the cell + * - a key is down and the cell is clicked by the mouse + */ + const onIdClicked = useCallback( + (e: React.KeyboardEvent, index: number) => { + if (e.key === NormalizedEventKey.Enter || e.key === NormalizedEventKey.Space) { + onSelectedRowsChange(new Set([index])); + } + }, + [onSelectedRowsChange], + ); const RenderRow = ({ index, style, data }: ReactWindowRenderFnProps) => { const { item, selected, appearance, onClick, onKeyDown } = data[index]; return ( - + {!isSelectionDisabled && ( { + setSelectionStartIndex(index); + onClick(e); + }} onKeyDown={onKeyDown} /> )} @@ -141,8 +190,9 @@ export const DocumentsTableComponent: React.FC = onSelectedRowsChange(new Set([index]))} - onKeyDown={() => onIdClicked(index)} + // When clicking on a cell with shift/ctrl key, onKeyDown is called instead of onClick. + onClick={(e: React.MouseEvent) => onTableCellClicked(e, index)} + onKeyPress={(e: React.KeyboardEvent) => onIdClicked(e, index)} {...columnSizing.getTableCellProps(column.columnId)} tabIndex={column.columnId === "id" ? 0 : -1} > @@ -166,7 +216,7 @@ export const DocumentsTableComponent: React.FC = [ useTableColumnSizing_unstable({ columnSizingOptions, onColumnResize }), useTableSelection({ - selectionMode: "multiselect", + selectionMode: isSelectionDisabled ? "single" : "multiselect", selectedItems: selectedRows, // eslint-disable-next-line react/prop-types onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems), @@ -207,9 +257,10 @@ export const DocumentsTableComponent: React.FC = if (newActiveItemIndex !== activeItemIndex) { onItemClicked(newActiveItemIndex); setActiveItemIndex(newActiveItemIndex); + setSelectionStartIndex(newActiveItemIndex); } } - }, [selectedRows, items]); + }, [selectedRows, items, activeItemIndex, onItemClicked]); // Cell keyboard navigation const keyboardNavAttr = useArrowNavigationGroup({ axis: "grid" }); diff --git a/src/Explorer/Tabs/DocumentsTabV2/SelectionHelper.ts b/src/Explorer/Tabs/DocumentsTabV2/SelectionHelper.ts new file mode 100644 index 000000000..fe4426d4e --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/SelectionHelper.ts @@ -0,0 +1,92 @@ +/** + * Utility class to help with selection. + * This emulates File Explorer selection behavior. + * ctrl: toggle selection of index. + * shift: select all rows between selectionStartIndex and index + * shift + ctrl: select or deselect all rows between selectionStartIndex and index depending on whether selectionStartIndex is selected + * No modifier only selects the clicked row + * ctrl: updates selection start index + * shift: do not update selection start index + * + * @param currentSelection current selection + * @param clickedIndex index of clicked row + * @param isShiftKey shift key is pressed + * @param isCtrlKey ctrl key is pressed + * @param selectionStartIndex index of current selected row + * @returns new selection and selection start + */ +export const selectionHelper = ( + currentSelection: Set, + clickedIndex: number, + isShiftKey: boolean, + isCtrlKey: boolean, + selectionStartIndex: number, +): { + selection: Set; + selectionStartIndex: number; +} => { + if (isShiftKey) { + // Shift is about selecting range of rows + if (isCtrlKey) { + // shift + ctrl + const isSelectionStartIndexSelected = currentSelection.has(selectionStartIndex); + const min = Math.min(clickedIndex, selectionStartIndex); + const max = Math.max(clickedIndex, selectionStartIndex); + + const newSelection = new Set(currentSelection); + for (let i = min; i <= max; i++) { + // Select or deselect range depending on how selectionStartIndex is selected + if (isSelectionStartIndexSelected) { + // Select range + newSelection.add(i); + } else { + // Deselect range + newSelection.delete(i); + } + } + + return { + selection: newSelection, + selectionStartIndex: undefined, + }; + } else { + // shift only + // Shift only: enable everything between lastClickedIndex and clickedIndex and disable everything else + const min = Math.min(clickedIndex, selectionStartIndex); + const max = Math.max(clickedIndex, selectionStartIndex); + const newSelection = new Set(); + for (let i = min; i <= max; i++) { + newSelection.add(i); + } + + return { + selection: newSelection, + selectionStartIndex: undefined, // do not change selection start + }; + } + } else { + if (isCtrlKey) { + // Ctrl only: toggle selection where we clicked + const isNotSelected = !currentSelection.has(clickedIndex); + if (isNotSelected) { + return { + selection: new Set(currentSelection.add(clickedIndex)), + selectionStartIndex: clickedIndex, + }; + } else { + // Remove + currentSelection.delete(clickedIndex); + return { + selection: new Set(currentSelection), + selectionStartIndex: clickedIndex, + }; + } + } else { + // If no modifier keys are pressed, select only the clicked row + return { + selection: new Set([clickedIndex]), + selectionStartIndex: clickedIndex, + }; + } + } +}; diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap index 44eb5db8b..2fbeba2da 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTableComponent.test.tsx.snap @@ -476,11 +476,13 @@ exports[`DocumentsTableComponent should not render selection column when isSelec key="1" style={ Object { + "cursor": "pointer", "height": 30, "left": 0, "position": "absolute", "right": undefined, "top": 0, + "userSelect": "none", "width": "100%", } } @@ -492,11 +494,13 @@ exports[`DocumentsTableComponent should not render selection column when isSelec role="row" style={ Object { + "cursor": "pointer", "height": 30, "left": 0, "position": "absolute", "right": undefined, "top": 0, + "userSelect": "none", "width": "100%", } } @@ -505,7 +509,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec className="documentsTableCell" key="id" onClick={[Function]} - onKeyDown={[Function]} + onKeyPress={[Function]} style={ Object { "maxWidth": 50, @@ -518,7 +522,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
{ + describe("when shift:off", () => { + it("ctrl:off: should return clicked items and update selection start", () => { + const currentSelection = new Set([1, 2, 3]); + const clickedIndex = 4; + const isShiftKey = false; + const isCtrlKey = false; + const selectionStartIndex = 1; + const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex); + expect(result.selection).toEqual(new Set([4])); + expect(result.selectionStartIndex).toEqual(4); + }); + + it("ctrl:on: should turn on selection and update selection start on not selected item", () => { + const currentSelection = new Set([1, 3]); + const clickedIndex = 2; + const isShiftKey = false; + const isCtrlKey = true; + const selectionStartIndex = 1; + const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex); + expect(result.selection).toEqual(new Set([1, 2, 3])); + expect(result.selectionStartIndex).toEqual(2); + }); + + it("ctrl:on: should turn off selection and update selection start on selected item", () => { + const currentSelection = new Set([1, 2, 3]); + const clickedIndex = 2; + const isShiftKey = false; + const isCtrlKey = true; + const selectionStartIndex = 1; + const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex); + expect(result.selection).toEqual(new Set([1, 3])); + expect(result.selectionStartIndex).toEqual(2); + }); + }); + + describe("when shift:on", () => { + it("ctrl:off: should only select between selection start and clicked index (selection start < clicked index)", () => { + const currentSelection = new Set([7, 8, 10]); + const clickedIndex = 9; + const isShiftKey = true; + const isCtrlKey = false; + const selectionStartIndex = 5; + const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex); + expect(result.selection).toEqual(new Set([5, 6, 7, 8, 9])); + expect(result.selectionStartIndex).toEqual(undefined); + }); + + it("ctrl:off: should only select between selection start and clicked index (selection start > clicked index)", () => { + const currentSelection = new Set([4, 6, 8]); + const clickedIndex = 2; + const isShiftKey = true; + const isCtrlKey = false; + const selectionStartIndex = 5; + const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex); + expect(result.selection).toEqual(new Set([2, 3, 4, 5])); + expect(result.selectionStartIndex).toEqual(undefined); + }); + + it("ctrl:on: selection start on selected item should keep current selection and select range, and not update selection start", () => { + const currentSelection = new Set([1, 4, 5, 7]); + const clickedIndex = 9; + const isShiftKey = true; + const isCtrlKey = true; + const selectionStartIndex = 5; + const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex); + expect(result.selection).toEqual(new Set([1, 4, 5, 6, 7, 8, 9])); + expect(result.selectionStartIndex).toEqual(undefined); + }); + + it("ctrl:on: selection start on deselected item should deselect range, and not update selection start", () => { + const currentSelection = new Set([1, 4, 6, 7, 10]); + const clickedIndex = 9; + const isShiftKey = true; + const isCtrlKey = true; + const selectionStartIndex = 5; + const result = selectionHelper(currentSelection, clickedIndex, isShiftKey, isCtrlKey, selectionStartIndex); + expect(result.selection).toEqual(new Set([1, 4, 10])); + expect(result.selectionStartIndex).toEqual(undefined); + }); + }); +}); diff --git a/src/Utils/KeyboardUtils.ts b/src/Utils/KeyboardUtils.ts new file mode 100644 index 000000000..e7ef66ef9 --- /dev/null +++ b/src/Utils/KeyboardUtils.ts @@ -0,0 +1,15 @@ +/** + * Is the environment 'ctrl' key press. This key is used for multi selection, like select one more item, select all. + * For Windows and Linux, it's ctrl. For Mac, it's command. + */ +export const isEnvironmentCtrlPressed = (event: JQueryEventObject | React.MouseEvent): boolean => + isMac() ? event.metaKey : event.ctrlKey; + +export const isEnvironmentShiftPressed = (event: JQueryEventObject | React.MouseEvent): boolean => event.shiftKey; + +export const isEnvironmentAltPressed = (event: JQueryEventObject | React.MouseEvent): boolean => event.altKey; + +/** + * Returns whether the current platform is MacOS. + */ +export const isMac = (): boolean => navigator.platform.toUpperCase().indexOf("MAC") >= 0;