From 18f9794d46465f301148dd2cfedd4105bc7ef5ff Mon Sep 17 00:00:00 2001 From: roman Date: Fri, 14 Aug 2026 18:06:32 +0200 Subject: [PATCH] refactor(table): migrate Table from Flow to TypeScript --- .../table/{Table.js => Table.js.flow} | 0 src/components/table/Table.tsx | 24 + .../table/{TableBody.js => TableBody.js.flow} | 0 src/components/table/TableBody.tsx | 15 + .../table/{TableCell.js => TableCell.js.flow} | 0 src/components/table/TableCell.tsx | 24 + .../{TableHeader.js => TableHeader.js.flow} | 0 src/components/table/TableHeader.tsx | 21 + ...eHeaderCell.js => TableHeaderCell.js.flow} | 0 src/components/table/TableHeaderCell.tsx | 24 + .../table/{TableRow.js => TableRow.js.flow} | 0 src/components/table/TableRow.tsx | 19 + .../{Table.test.js => Table.test.tsx} | 1 + .../{TableBody.test.js => TableBody.test.tsx} | 1 + .../{TableCell.test.js => TableCell.test.tsx} | 1 + ...bleHeader.test.js => TableHeader.test.tsx} | 1 + ...rCell.test.js => TableHeaderCell.test.tsx} | 1 + .../{TableRow.test.js => TableRow.test.tsx} | 1 + ...ctable.test.js => makeSelectable.test.tsx} | 14 +- .../table/__tests__/shiftSelect.test.js | 36 - .../table/__tests__/shiftSelect.test.ts | 38 + .../table/{index.js => index.js.flow} | 0 src/components/table/index.ts | 14 + ...keSelectable.js => makeSelectable.js.flow} | 0 src/components/table/makeSelectable.tsx | 723 ++++++++++++++++++ .../table/{messages.js => messages.js.flow} | 0 src/components/table/messages.ts | 41 + .../{shiftSelect.js => shiftSelect.js.flow} | 0 src/components/table/shiftSelect.ts | 59 ++ 29 files changed, 1016 insertions(+), 42 deletions(-) rename src/components/table/{Table.js => Table.js.flow} (100%) create mode 100644 src/components/table/Table.tsx rename src/components/table/{TableBody.js => TableBody.js.flow} (100%) create mode 100644 src/components/table/TableBody.tsx rename src/components/table/{TableCell.js => TableCell.js.flow} (100%) create mode 100644 src/components/table/TableCell.tsx rename src/components/table/{TableHeader.js => TableHeader.js.flow} (100%) create mode 100644 src/components/table/TableHeader.tsx rename src/components/table/{TableHeaderCell.js => TableHeaderCell.js.flow} (100%) create mode 100644 src/components/table/TableHeaderCell.tsx rename src/components/table/{TableRow.js => TableRow.js.flow} (100%) create mode 100644 src/components/table/TableRow.tsx rename src/components/table/__tests__/{Table.test.js => Table.test.tsx} (97%) rename src/components/table/__tests__/{TableBody.test.js => TableBody.test.tsx} (95%) rename src/components/table/__tests__/{TableCell.test.js => TableCell.test.tsx} (97%) rename src/components/table/__tests__/{TableHeader.test.js => TableHeader.test.tsx} (97%) rename src/components/table/__tests__/{TableHeaderCell.test.js => TableHeaderCell.test.tsx} (97%) rename src/components/table/__tests__/{TableRow.test.js => TableRow.test.tsx} (95%) rename src/components/table/__tests__/{makeSelectable.test.js => makeSelectable.test.tsx} (99%) delete mode 100644 src/components/table/__tests__/shiftSelect.test.js create mode 100644 src/components/table/__tests__/shiftSelect.test.ts rename src/components/table/{index.js => index.js.flow} (100%) create mode 100644 src/components/table/index.ts rename src/components/table/{makeSelectable.js => makeSelectable.js.flow} (100%) create mode 100644 src/components/table/makeSelectable.tsx rename src/components/table/{messages.js => messages.js.flow} (100%) create mode 100644 src/components/table/messages.ts rename src/components/table/{shiftSelect.js => shiftSelect.js.flow} (100%) create mode 100644 src/components/table/shiftSelect.ts diff --git a/src/components/table/Table.js b/src/components/table/Table.js.flow similarity index 100% rename from src/components/table/Table.js rename to src/components/table/Table.js.flow diff --git a/src/components/table/Table.tsx b/src/components/table/Table.tsx new file mode 100644 index 0000000000..522d9f8863 --- /dev/null +++ b/src/components/table/Table.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +export interface TableProps extends React.TableHTMLAttributes { + /** Contents of the table */ + children: React.ReactNode; + /** Additional CSS class for the table */ + className?: string; + /** Whether to render the table in compact mode */ + isCompact?: boolean; +} + +const Table = ({ children, className = '', isCompact = false, ...rest }: TableProps) => ( + + {children} +
+); + +export default Table; diff --git a/src/components/table/TableBody.js b/src/components/table/TableBody.js.flow similarity index 100% rename from src/components/table/TableBody.js rename to src/components/table/TableBody.js.flow diff --git a/src/components/table/TableBody.tsx b/src/components/table/TableBody.tsx new file mode 100644 index 0000000000..6bfa312627 --- /dev/null +++ b/src/components/table/TableBody.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +export interface TableBodyProps { + /** Body rows of the table */ + children: React.ReactNode; + /** Additional CSS class for the table body */ + className?: string; +} + +const TableBody = ({ children, className = '' }: TableBodyProps) => ( + {children} +); + +export default TableBody; diff --git a/src/components/table/TableCell.js b/src/components/table/TableCell.js.flow similarity index 100% rename from src/components/table/TableCell.js rename to src/components/table/TableCell.js.flow diff --git a/src/components/table/TableCell.tsx b/src/components/table/TableCell.tsx new file mode 100644 index 0000000000..af17e62196 --- /dev/null +++ b/src/components/table/TableCell.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +export interface TableCellProps extends React.TdHTMLAttributes { + /** Contents of the table cell */ + children: React.ReactNode; + /** Additional CSS class for the cell */ + className?: string; + /** Whether the cell has a fixed width */ + isFixedWidth?: boolean; +} + +const TableCell = ({ children, className = '', isFixedWidth = false, ...rest }: TableCellProps) => ( + + {children} + +); + +export default TableCell; diff --git a/src/components/table/TableHeader.js b/src/components/table/TableHeader.js.flow similarity index 100% rename from src/components/table/TableHeader.js rename to src/components/table/TableHeader.js.flow diff --git a/src/components/table/TableHeader.tsx b/src/components/table/TableHeader.tsx new file mode 100644 index 0000000000..1e322c419a --- /dev/null +++ b/src/components/table/TableHeader.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +import TableRow from './TableRow'; + +export interface TableHeaderProps { + /** Header cells of the table */ + children: React.ReactNode; + /** Additional CSS class for the table header */ + className?: string; + /** Additional CSS class for the header row */ + rowClassName?: string; +} + +const TableHeader = ({ children, className = '', rowClassName = '' }: TableHeaderProps) => ( + + {children} + +); + +export default TableHeader; diff --git a/src/components/table/TableHeaderCell.js b/src/components/table/TableHeaderCell.js.flow similarity index 100% rename from src/components/table/TableHeaderCell.js rename to src/components/table/TableHeaderCell.js.flow diff --git a/src/components/table/TableHeaderCell.tsx b/src/components/table/TableHeaderCell.tsx new file mode 100644 index 0000000000..4edcdbfea3 --- /dev/null +++ b/src/components/table/TableHeaderCell.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +export interface TableHeaderCellProps extends React.ThHTMLAttributes { + /** Contents of the header cell */ + children?: React.ReactNode; + /** Additional CSS class for the header cell */ + className?: string; + /** Whether the header cell has a fixed width */ + isFixedWidth?: boolean; +} + +const TableHeaderCell = ({ children, className = '', isFixedWidth = false, ...rest }: TableHeaderCellProps) => ( + + {children} + +); + +export default TableHeaderCell; diff --git a/src/components/table/TableRow.js b/src/components/table/TableRow.js.flow similarity index 100% rename from src/components/table/TableRow.js rename to src/components/table/TableRow.js.flow diff --git a/src/components/table/TableRow.tsx b/src/components/table/TableRow.tsx new file mode 100644 index 0000000000..8ec77b54aa --- /dev/null +++ b/src/components/table/TableRow.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +export interface TableRowProps extends React.HTMLAttributes { + /** Cells of the table row */ + children: React.ReactNode; + /** Additional CSS class for the row */ + className?: string; + /** Ref for the table row element */ + rowRef?: React.Ref; +} + +const TableRow = ({ children, className = '', rowRef, ...rest }: TableRowProps) => ( + + {children} + +); + +export default TableRow; diff --git a/src/components/table/__tests__/Table.test.js b/src/components/table/__tests__/Table.test.tsx similarity index 97% rename from src/components/table/__tests__/Table.test.js rename to src/components/table/__tests__/Table.test.tsx index 00f4e28a5d..bebe025308 100644 --- a/src/components/table/__tests__/Table.test.js +++ b/src/components/table/__tests__/Table.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import Table from '../Table'; diff --git a/src/components/table/__tests__/TableBody.test.js b/src/components/table/__tests__/TableBody.test.tsx similarity index 95% rename from src/components/table/__tests__/TableBody.test.js rename to src/components/table/__tests__/TableBody.test.tsx index 66f51b7a33..f905ec8ffc 100644 --- a/src/components/table/__tests__/TableBody.test.js +++ b/src/components/table/__tests__/TableBody.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import TableBody from '../TableBody'; diff --git a/src/components/table/__tests__/TableCell.test.js b/src/components/table/__tests__/TableCell.test.tsx similarity index 97% rename from src/components/table/__tests__/TableCell.test.js rename to src/components/table/__tests__/TableCell.test.tsx index 3537ab4d0f..a49c91d031 100644 --- a/src/components/table/__tests__/TableCell.test.js +++ b/src/components/table/__tests__/TableCell.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import TableCell from '../TableCell'; diff --git a/src/components/table/__tests__/TableHeader.test.js b/src/components/table/__tests__/TableHeader.test.tsx similarity index 97% rename from src/components/table/__tests__/TableHeader.test.js rename to src/components/table/__tests__/TableHeader.test.tsx index 5812248551..b67ee81a4a 100644 --- a/src/components/table/__tests__/TableHeader.test.js +++ b/src/components/table/__tests__/TableHeader.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import TableHeader from '../TableHeader'; diff --git a/src/components/table/__tests__/TableHeaderCell.test.js b/src/components/table/__tests__/TableHeaderCell.test.tsx similarity index 97% rename from src/components/table/__tests__/TableHeaderCell.test.js rename to src/components/table/__tests__/TableHeaderCell.test.tsx index c38e209881..1d66b72f43 100644 --- a/src/components/table/__tests__/TableHeaderCell.test.js +++ b/src/components/table/__tests__/TableHeaderCell.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import TableHeaderCell from '../TableHeaderCell'; diff --git a/src/components/table/__tests__/TableRow.test.js b/src/components/table/__tests__/TableRow.test.tsx similarity index 95% rename from src/components/table/__tests__/TableRow.test.js rename to src/components/table/__tests__/TableRow.test.tsx index a44b4db239..5baa4b61cd 100644 --- a/src/components/table/__tests__/TableRow.test.js +++ b/src/components/table/__tests__/TableRow.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import TableRow from '../TableRow'; diff --git a/src/components/table/__tests__/makeSelectable.test.js b/src/components/table/__tests__/makeSelectable.test.tsx similarity index 99% rename from src/components/table/__tests__/makeSelectable.test.js rename to src/components/table/__tests__/makeSelectable.test.tsx index 845e85a2cc..50b1b59a07 100644 --- a/src/components/table/__tests__/makeSelectable.test.js +++ b/src/components/table/__tests__/makeSelectable.test.tsx @@ -1,6 +1,8 @@ +// @ts-nocheck Enzyme instance() is untyped for this HOC class component import * as React from 'react'; import { Set } from 'immutable'; import sinon from 'sinon'; +import { shallow } from 'enzyme'; import isEqual from 'lodash/isEqual'; @@ -35,7 +37,7 @@ describe('components/table/makeSelectable', () => { const instance = wrapper.instance(); const shortcut = instance.getHotkeyConfigs().find(h => h.get('key') === hotKey); shortcut.handler({ preventDefault: sandbox.stub() }); - expect(wrapper.state('focusedIndex')).toEqual(undefined); + expect(wrapper.state('focusedIndex')).toBeUndefined(); }; afterEach(() => { @@ -750,7 +752,7 @@ describe('components/table/makeSelectable', () => { const instance = wrapper.instance(); const shortcut = instance.getHotkeyConfigs().find(h => h.get('key') === hotKey); shortcut.handler({ preventDefault: sandbox.stub() }); - expect(wrapper.state('focusedIndex')).toEqual(undefined); + expect(wrapper.state('focusedIndex')).toBeUndefined(); }); }); @@ -956,7 +958,7 @@ describe('components/table/makeSelectable', () => { const instance = wrapper.instance(); const shortcut = instance.getHotkeyConfigs().find(h => h.get('key') === hotKey); shortcut.handler({ target: { role: 'slider' } }); - expect(wrapper.state('focusedIndex')).toEqual(undefined); + expect(wrapper.state('focusedIndex')).toBeUndefined(); }); test('should call event.preventDefault() and set focus to next item', () => { @@ -1006,7 +1008,7 @@ describe('components/table/makeSelectable', () => { const instance = wrapper.instance(); const shortcut = instance.getHotkeyConfigs().find(h => h.get('key') === hotKey); shortcut.handler({ target: { role: 'slider' } }); - expect(wrapper.state('focusedIndex')).toEqual(undefined); + expect(wrapper.state('focusedIndex')).toBeUndefined(); }); test('should call event.preventDefault() and call onSelect with new focused item', () => { @@ -1081,7 +1083,7 @@ describe('components/table/makeSelectable', () => { const instance = wrapper.instance(); const shortcut = instance.getHotkeyConfigs().find(h => h.get('key') === hotKey); shortcut.handler({ target: { role: 'slider' } }); - expect(wrapper.state('focusedIndex')).toEqual(undefined); + expect(wrapper.state('focusedIndex')).toBeUndefined(); }); test('should call event.preventDefault() and set focus to next row item', () => { @@ -1131,7 +1133,7 @@ describe('components/table/makeSelectable', () => { const instance = wrapper.instance(); const shortcut = instance.getHotkeyConfigs().find(h => h.get('key') === hotKey); shortcut.handler({ target: { role: 'slider' } }); - expect(wrapper.state('focusedIndex')).toEqual(undefined); + expect(wrapper.state('focusedIndex')).toBeUndefined(); }); test('should call event.preventDefault() and call onSelect with new focused item', () => { diff --git a/src/components/table/__tests__/shiftSelect.test.js b/src/components/table/__tests__/shiftSelect.test.js deleted file mode 100644 index 57363009e9..0000000000 --- a/src/components/table/__tests__/shiftSelect.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { Set } from 'immutable'; - -import shiftSelect from '../shiftSelect'; - -describe('components/table/shiftSelect', () => { - [ - // prevSelection, prevTarget, target, anchor, expected - // [PrevTarget, Anchor, Target] - [[1, 2], 0, 3, 1, [1, 2, 3]], - [[0], 0, 2, 1, [1, 2]], - // [PrevTarget, Target, Anchor] - [[3, 4], 0, 1, 2, [1, 2, 3, 4]], - [[0], 0, 1, 2, [1, 2]], - // [Anchor, PrevTarget, Target] - [[0, 1, 9], 1, 2, 0, [0, 1, 2, 9]], - // [Anchor, Target, PrevTarget] - [[0, 1, 2, 3, 4, 9], 4, 2, 0, [0, 1, 2, 9]], - // [Target, Anchor, PrevTarget] - [[0, 1, 2, 3, 4, 9], 4, 0, 2, [0, 1, 2, 9]], - // [Target, PrevTarget, Anchor] - [[2, 3, 4, 9], 2, 0, 4, [0, 1, 2, 3, 4, 9]], - ].forEach(([prevSelection, prevTarget, target, anchor, expected], index) => { - const expectedSet = new Set(expected); - test(`should select the correct elements (data set #${index})`, () => { - const ret = shiftSelect(new Set(prevSelection), prevTarget, target, anchor); - expect(ret.equals(expectedSet)).toBeTruthy(); - }); - }); - - test('should throw when params are invalid (very rare)', () => { - const prevSelection = new Set([1, 2, 3, 4]); - expect(() => { - shiftSelect(prevSelection, undefined, undefined, undefined); - }).toThrow(); - }); -}); diff --git a/src/components/table/__tests__/shiftSelect.test.ts b/src/components/table/__tests__/shiftSelect.test.ts new file mode 100644 index 0000000000..fd571dea0e --- /dev/null +++ b/src/components/table/__tests__/shiftSelect.test.ts @@ -0,0 +1,38 @@ +import { Set } from 'immutable'; + +import shiftSelect from '../shiftSelect'; + +describe('components/table/shiftSelect', () => { + ( + [ + // prevSelection, prevTarget, target, anchor, expected + // [PrevTarget, Anchor, Target] + [[1, 2], 0, 3, 1, [1, 2, 3]], + [[0], 0, 2, 1, [1, 2]], + // [PrevTarget, Target, Anchor] + [[3, 4], 0, 1, 2, [1, 2, 3, 4]], + [[0], 0, 1, 2, [1, 2]], + // [Anchor, PrevTarget, Target] + [[0, 1, 9], 1, 2, 0, [0, 1, 2, 9]], + // [Anchor, Target, PrevTarget] + [[0, 1, 2, 3, 4, 9], 4, 2, 0, [0, 1, 2, 9]], + // [Target, Anchor, PrevTarget] + [[0, 1, 2, 3, 4, 9], 4, 0, 2, [0, 1, 2, 9]], + // [Target, PrevTarget, Anchor] + [[2, 3, 4, 9], 2, 0, 4, [0, 1, 2, 3, 4, 9]], + ] as Array<[number[], number, number, number, number[]]> + ).forEach(([prevSelection, prevTarget, target, anchor, expected], index) => { + const expectedSet = Set(expected); + test(`should select the correct elements (data set #${index})`, () => { + const ret = shiftSelect(Set(prevSelection), prevTarget, target, anchor); + expect(ret.equals(expectedSet)).toBeTruthy(); + }); + }); + + test('should throw when params are invalid (very rare)', () => { + const prevSelection = Set([1, 2, 3, 4]); + expect(() => { + shiftSelect(prevSelection, undefined, undefined, undefined); + }).toThrow(); + }); +}); diff --git a/src/components/table/index.js b/src/components/table/index.js.flow similarity index 100% rename from src/components/table/index.js rename to src/components/table/index.js.flow diff --git a/src/components/table/index.ts b/src/components/table/index.ts new file mode 100644 index 0000000000..e54fa99acc --- /dev/null +++ b/src/components/table/index.ts @@ -0,0 +1,14 @@ +export { default as Table } from './Table'; +export { default as TableBody } from './TableBody'; +export { default as TableCell } from './TableCell'; +export { default as TableHeader } from './TableHeader'; +export { default as TableHeaderCell } from './TableHeaderCell'; +export { default as TableRow } from './TableRow'; +export { default as makeSelectable } from './makeSelectable'; +export type { TableProps } from './Table'; +export type { TableBodyProps } from './TableBody'; +export type { TableCellProps } from './TableCell'; +export type { TableHeaderProps } from './TableHeader'; +export type { TableHeaderCellProps } from './TableHeaderCell'; +export type { TableRowProps } from './TableRow'; +export type { MakeSelectableProps } from './makeSelectable'; diff --git a/src/components/table/makeSelectable.js b/src/components/table/makeSelectable.js.flow similarity index 100% rename from src/components/table/makeSelectable.js rename to src/components/table/makeSelectable.js.flow diff --git a/src/components/table/makeSelectable.tsx b/src/components/table/makeSelectable.tsx new file mode 100644 index 0000000000..2d912db5be --- /dev/null +++ b/src/components/table/makeSelectable.tsx @@ -0,0 +1,723 @@ +import * as React from 'react'; +import { Set } from 'immutable'; +import classNames from 'classnames'; +import { FormattedMessage } from 'react-intl'; + +import { Hotkeys, HotkeyRecord } from '../hotkeys'; +import messages from './messages'; +import shiftSelect from './shiftSelect'; + +const SEARCH_TIMER_DURATION = 1000; + +export interface MakeSelectableProps { + /** Additional CSS class for the table */ + className?: string; + /** Array of unique IDs of the items in the table. Each item should be a string or number, in the order they appear in the table. */ + data: Array; + /** Number of columns when rendering in grid view */ + gridColumnCount?: number; + /** Whether the table is displayed as a grid */ + isGridView?: boolean; + /** Called when focus changes. `(focusedIndex: number) => void` */ + onFocus?: (focusedIndex: number | undefined) => void; + /** Called when selection changes. `(selectedItems: Array | Array | Set | Set) => void` */ + onSelect: (selectedItems: Array | Set) => void; + /** + * Array of strings for keyboard search corresponding to the data prop. If not provided, keyboard search won't work. + * Example: data = ['f_123', 'f_456'], and corresponding searchStrings = ['file.png', 'another file.pdf'] + */ + searchStrings?: Array; + /** + * Array of IDs that are currently selected, in any order. + * If you pass a native JS array, then your onSelect function will be called with a native JS array; + * likewise, if you pass an ImmutableJS Set, then your onSelect function will be called + * with an ImmutableJS Set. + */ + selectedItems?: Array | Set; + /** Array of unique IDs of the items in the table that are loaded and accessible. If not provided, this will default to all data */ + loadedData?: Array; + /** Whether keyboard shortcuts are enabled */ + enableHotkeys?: boolean; + /** Translated type for hotkeys. If not provided, then the hotkeys will not appear in the help modal. */ + hotkeyType?: string; +} + +interface MakeSelectableState { + /** Index of the currently focused row, or undefined when no row is focused */ + focusedIndex: number | undefined; +} + +function makeSelectable

(BaseTable: React.ComponentType

) { + const originalDisplayName = BaseTable.displayName || BaseTable.name || 'Table'; + + return class SelectableTable extends React.Component

{ + static displayName = `Selectable(${originalDisplayName})`; + + static defaultProps: Partial = { + selectedItems: Set(), + }; + + anchorIndex: number; + + searchString: string; + + searchTimeout: ReturnType | null; + + previousIndex: number; + + blurTimerID: ReturnType | null; + + constructor(props: P & MakeSelectableProps) { + super(props); + + this.anchorIndex = 0; + + this.searchString = ''; + this.searchTimeout = null; + + // we have to store the previously focused index because a focus event + // will be fired before the click event; thus, in the click handler, + // the focusedItem will already be the new item + this.previousIndex = 0; + + this.blurTimerID = null; + } + + state = { + focusedIndex: undefined, + }; + + componentDidMount() { + document.addEventListener('keypress', this.handleKeyboardSearch as unknown as EventListener); + } + + componentDidUpdate(prevProps: P & MakeSelectableProps, prevState: MakeSelectableState) { + if (prevState.focusedIndex !== this.state.focusedIndex && this.props.onFocus) { + this.props.onFocus(this.state.focusedIndex); + } + } + + componentWillUnmount() { + document.removeEventListener('keypress', this.handleKeyboardSearch as unknown as EventListener); + clearTimeout(this.blurTimerID); + } + + onSelect = (selectedItems: Set, newFocusedIndex: number | undefined) => { + const { onSelect } = this.props; + + this.previousIndex = this.state.focusedIndex || 0; + + this.setState({ + focusedIndex: newFocusedIndex, + }); + + if (onSelect) { + // If selectedItems were given as an Immutable Set, they should also be returned as one, + // and vice versa if they were given as a native JS array + onSelect(Set.isSet(this.props.selectedItems) ? selectedItems : selectedItems.toJS()); + } + }; + + getSharedHotkeyConfigs = () => { + const { hotkeyType } = this.props; + return [ + new HotkeyRecord({ + key: 'shift+x', + description: , + handler: () => { + const { focusedIndex } = this.state; + + if (focusedIndex === undefined) { + return; + } + + this.selectToggle(focusedIndex); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: ['meta+a', 'ctrl+a'], + description: , + handler: event => { + const { data } = this.props; + + event.preventDefault(); + + this.onSelect(Set(data), this.state.focusedIndex); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'esc', + description: , + handler: () => { + this.onSelect(Set(), this.state.focusedIndex); + }, + type: hotkeyType, + }), + ]; + }; + + getListViewHotKeyConfigs = () => { + const { hotkeyType } = this.props; + return [ + new HotkeyRecord({ + key: 'down', + description: , + handler: event => { + if (this.shouldNotAllowArrowKeyNavigation(event)) { + return; + } + + const { data } = this.props; + const { focusedIndex } = this.state; + + event.preventDefault(); + + const newFocusedIndex = + focusedIndex !== undefined ? Math.min(focusedIndex + 1, data.length - 1) : 0; + this.setState({ focusedIndex: newFocusedIndex }); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'up', + description: , + handler: event => { + if (this.shouldNotAllowArrowKeyNavigation(event)) { + return; + } + + const { focusedIndex = 0 } = this.state; + + event.preventDefault(); + + const newFocusedIndex = Math.max(focusedIndex - 1, 0); + this.setState({ focusedIndex: newFocusedIndex }); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'shift+down', + description: , + handler: () => { + const { data } = this.props; + const { focusedIndex } = this.state; + + if (focusedIndex === undefined) { + return; + } + + const newFocusedIndex = Math.min(focusedIndex + 1, data.length - 1); + this.handleShiftKeyDown(newFocusedIndex, data.length - 1); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'shift+up', + description: , + handler: () => { + const { focusedIndex } = this.state; + + if (focusedIndex === undefined) { + return; + } + + const newFocusedIndex = Math.max(focusedIndex - 1, 0); + this.handleShiftKeyDown(newFocusedIndex, 0); + }, + type: hotkeyType, + }), + ]; + }; + + getGridViewHotKeyConfigs = () => { + const { hotkeyType } = this.props; + return [ + new HotkeyRecord({ + key: 'right', + description: , + handler: event => { + if (this.isTargetSlider(event) || this.shouldNotAllowArrowKeyNavigation(event)) { + return; + } + + const { data } = this.props; + const { focusedIndex } = this.state; + + event.preventDefault(); + + const newFocusedIndex = + focusedIndex !== undefined ? Math.min(focusedIndex + 1, data.length - 1) : 0; + this.setState({ focusedIndex: newFocusedIndex }); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'left', + description: , + handler: event => { + if (this.isTargetSlider(event) || this.shouldNotAllowArrowKeyNavigation(event)) { + return; + } + + const { focusedIndex = 0 } = this.state; + + event.preventDefault(); + + const newFocusedIndex = Math.max(focusedIndex - 1, 0); + this.setState({ focusedIndex: newFocusedIndex }); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'down', + description: , + handler: event => { + if (this.isTargetSlider(event) || this.shouldNotAllowArrowKeyNavigation(event)) { + return; + } + + const { data, gridColumnCount } = this.props; + const { focusedIndex } = this.state; + + event.preventDefault(); + + const newFocusedIndex = + focusedIndex !== undefined ? Math.min(focusedIndex + gridColumnCount, data.length - 1) : 0; + this.setState({ focusedIndex: newFocusedIndex }); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'up', + description: , + handler: event => { + if (this.isTargetSlider(event) || this.shouldNotAllowArrowKeyNavigation(event)) { + return; + } + + const { gridColumnCount } = this.props; + const { focusedIndex = 0 } = this.state; + + event.preventDefault(); + + const newFocusedIndex = Math.max(focusedIndex - gridColumnCount, 0); + this.setState({ focusedIndex: newFocusedIndex }); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'shift+right', + description: , + handler: () => { + const { data } = this.props; + const { focusedIndex } = this.state; + + if (focusedIndex === undefined) { + return; + } + + const newFocusedIndex = Math.min(focusedIndex + 1, data.length - 1); + this.handleShiftKeyDownForGrid(newFocusedIndex); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'shift+left', + description: , + handler: () => { + const { focusedIndex } = this.state; + + if (focusedIndex === undefined) { + return; + } + + const newFocusedIndex = Math.max(focusedIndex - 1, 0); + this.handleShiftKeyDownForGrid(newFocusedIndex); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'shift+down', + description: , + handler: () => { + const { data, gridColumnCount } = this.props; + const { focusedIndex } = this.state; + + if (focusedIndex === undefined) { + return; + } + + const newFocusedIndex = Math.min(focusedIndex + gridColumnCount, data.length - 1); + this.handleShiftKeyDownForGrid(newFocusedIndex); + }, + type: hotkeyType, + }), + new HotkeyRecord({ + key: 'shift+up', + description: , + handler: () => { + const { gridColumnCount } = this.props; + const { focusedIndex } = this.state; + + if (focusedIndex === undefined) { + return; + } + + const newFocusedIndex = Math.max(focusedIndex - gridColumnCount, 0); + this.handleShiftKeyDownForGrid(newFocusedIndex); + }, + type: hotkeyType, + }), + ]; + }; + + getHotkeyConfigs = () => { + const { enableHotkeys, isGridView, gridColumnCount } = this.props; + + if (!enableHotkeys && !this.hotkeys) { + this.hotkeys = []; + } + + if (!this.hotkeys) { + const viewSpecificHotKeyConfigs = + isGridView && gridColumnCount !== undefined + ? this.getGridViewHotKeyConfigs() + : this.getListViewHotKeyConfigs(); + + this.hotkeys = [...this.getSharedHotkeyConfigs(), ...viewSpecificHotKeyConfigs]; + } + + return this.hotkeys; + }; + + getProcessedProps = (): P & + MakeSelectableProps & { + loadedData: Set; + selectedItems: Set; + } => { + const { data, loadedData, selectedItems } = this.props; + return { + ...this.props, + loadedData: loadedData ? Set(loadedData) : Set(data), + selectedItems: (Set.isSet(selectedItems) ? selectedItems : Set(selectedItems)) as Set, + }; + }; + + hotkeys = null; + + selectToggle = (rowIndex: number) => { + const { data, selectedItems } = this.getProcessedProps(); + + if (selectedItems.has(data[rowIndex])) { + this.onSelect(selectedItems.delete(data[rowIndex]), rowIndex); + } else { + this.onSelect(selectedItems.add(data[rowIndex]), rowIndex); + } + + this.anchorIndex = rowIndex; + }; + + selectRange = (rowIndex: number) => { + const { data, selectedItems } = this.getProcessedProps(); + + // Don't change selection if we're shift-clicking the same row + if (rowIndex === this.previousIndex) { + return; + } + + // Converts set of items to set of indices to do some slicing magic + const selectedRows = Set( + data.reduce((rows, item, i) => { + if (selectedItems.has(item)) { + rows.push(i); + } + return rows; + }, []), + ); + + const newSelectedRows = shiftSelect(selectedRows, this.previousIndex, rowIndex, this.anchorIndex); + + // Converts set back to set of items + const newSelectedItems = newSelectedRows.map(i => data[i]); + + this.onSelect(newSelectedItems, rowIndex); + }; + + selectOne = (rowIndex: number) => { + const { data, selectedItems } = this.getProcessedProps(); + + // Don't change selection if we're clicking on a row that we've already selected + // This allows us to use the native onDoubleClick handler because we're referencing the + // same DOM node on double-click. + if (selectedItems.has(data[rowIndex]) && selectedItems.size === 1) { + return; + } + + this.onSelect(Set([data[rowIndex]]), rowIndex); + this.anchorIndex = rowIndex; + }; + + clearFocus = () => { + this.setState({ + focusedIndex: undefined, + }); + }; + + handleRowClick = (event: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }, index: number) => { + if (event.metaKey || event.ctrlKey) { + this.selectToggle(index); + } else if (event.shiftKey) { + this.selectRange(index); + } else { + this.selectOne(index); + } + }; + + handleRowFocus = (event: unknown, index: number) => { + const { selectedItems } = this.getProcessedProps(); + this.onSelect(selectedItems, index); + }; + + handleTableBlur = () => { + const { focusedIndex } = this.state; + if (focusedIndex !== undefined) { + // table may get focus back right away in the same tick, in which case we shouldn't clear focus + this.blurTimerID = setTimeout(this.clearFocus); + } + }; + + handleTableFocus = () => { + clearTimeout(this.blurTimerID); + }; + + handleShiftKeyDown = (newFocusedIndex: number, boundary: number) => { + const { data, selectedItems } = this.getProcessedProps(); + const { focusedIndex } = this.state; + + const focusedIndexData = data[focusedIndex]; + const newFocusedIndexData = data[newFocusedIndex]; + + // if we're at a boundary of the table and the row is selected, no-op + if (focusedIndex === boundary && selectedItems.has(focusedIndexData)) { + return; + } + + // if both the target and source are not selected, select them both + if (!selectedItems.has(focusedIndexData) && !selectedItems.has(newFocusedIndexData)) { + this.onSelect(selectedItems.union([focusedIndexData, newFocusedIndexData]), newFocusedIndex); + return; + } + + // if target is not selected, select it + if (!selectedItems.has(newFocusedIndexData)) { + this.onSelect(selectedItems.add(newFocusedIndexData), newFocusedIndex); + return; + } + + // if both source and target are selected, deselect source + if (selectedItems.has(newFocusedIndexData) && selectedItems.has(focusedIndexData)) { + this.onSelect(selectedItems.delete(focusedIndexData), newFocusedIndex); + return; + } + + // if target is selected and source is not, select source + this.onSelect(selectedItems.add(focusedIndexData), newFocusedIndex); + }; + + isContiguousSelection = (selectedItemIndecies: Set, sourceIndex: number, targetIndex: number) => { + if (sourceIndex < targetIndex && selectedItemIndecies.has(sourceIndex - 1)) { + return true; + } + if (targetIndex < sourceIndex && selectedItemIndecies.has(sourceIndex + 1)) { + return true; + } + return false; + }; + + handleShiftKeyDownForGrid = (newFocusedIndex: number) => { + const { data, loadedData, selectedItems } = this.getProcessedProps(); + const { focusedIndex } = this.state; + + const dataSize = data.length; + const targetIndex = newFocusedIndex < 0 ? 0 : Math.min(newFocusedIndex, dataSize - 1); + const isSourceSelected = selectedItems.has(data[focusedIndex]); + const isTargetSelected = selectedItems.has(data[targetIndex]); + + // if data is not loaded, we don't want it to be able to be selected + if (!loadedData.has(data[targetIndex])) { + return; + } + + const selectedItemIndices = Set( + data.reduce((rows, item, i) => { + if (selectedItems.has(item)) { + rows.push(i); + } + return rows; + }, []), + ); + + // reset the anchor on a new selection block + if ( + !isSourceSelected && + !isTargetSelected && + // if we are starting a new mass selection adjacent selected block, we want to connect them + !this.isContiguousSelection(selectedItemIndices, focusedIndex, targetIndex) + ) { + this.anchorIndex = focusedIndex; + } + + const newSelectedItemIndices = shiftSelect( + selectedItemIndices, + focusedIndex, + targetIndex, + this.anchorIndex, + ); + + const newSelectedItems = newSelectedItemIndices.map(i => data[i]); + + this.onSelect(newSelectedItems, targetIndex); + }; + + handleKeyboardSearch = (event: { + key: string; + which?: number; + target: { + hasAttribute: (name: string) => boolean; + nodeName: string; + }; + }) => { + const { searchStrings } = this.props; + + if (!searchStrings) { + return; + } + + if ( + event.target.hasAttribute('contenteditable') || + event.target.nodeName === 'INPUT' || + event.target.nodeName === 'TEXTAREA' + ) { + return; + } + + // character keys have a value for event.which + if (event.which === 0) { + return; + } + + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + this.searchString += event.key; + this.searchTimeout = setTimeout(() => { + this.searchString = ''; + }, SEARCH_TIMER_DURATION); + + const index = searchStrings.findIndex( + string => string.trim().toLowerCase().indexOf(this.searchString) === 0, + ); + + if (index !== -1) { + this.setState({ focusedIndex: index }); + } + }; + + handleCheckboxClick = (event: { nativeEvent: { shiftKey?: boolean } }, index: number) => { + if (event.nativeEvent.shiftKey) { + this.selectRange(index); + } else { + this.selectToggle(index); + } + }; + + isTargetSlider = (event: { target?: { role?: string } }) => event.target?.role === 'slider'; + + // Workaround for focus conflicting with Blueprint components for QuickSearch result, recent items and Quick Filters + isTargetQuickSearch = (event: { target?: { className?: string; dataset?: DOMStringMap } }) => { + if (!event.target) { + return false; + } + + const { className, dataset } = event.target; + + // Quick Search Button (See All etc) + if (className?.includes('bp_text_button_module')) { + return true; + } + + // QuickSearch Recent Item + if (className?.includes('quickSearchRecentItem')) { + return true; + } + + // Quick Search Result Item and Footer + if (className?.includes('quickSearchResultItem') || className?.includes('quickSearchQueryFooter')) { + return true; + } + + // Blueprint's + if (dataset && 'radixCollectionItem' in dataset) { + return true; + } + + // Blueprint's + if (dataset && 'bpSmallListItem' in dataset) { + return true; + } + + return false; + }; + + isFlyoutOpen = () => document.querySelector('.flyout-overlay') !== null; + + isDropdownMenuOpen = () => + document.querySelector('.dropdown-menu-element') !== null || + document.querySelector('[role="menu"]') !== null; + + shouldNotAllowArrowKeyNavigation = (event: { target?: { className?: string; dataset?: DOMStringMap } }) => + this.isTargetQuickSearch(event) || this.isFlyoutOpen() || this.isDropdownMenuOpen(); + + render() { + const { className, data } = this.props; + const { focusedIndex } = this.state; + const focusedItem = data[focusedIndex]; + const TableComponent = BaseTable as React.ComponentType< + P & { + className?: string; + focusedIndex?: number; + focusedItem?: string | number; + onCheckboxClick: (event: { nativeEvent: { shiftKey?: boolean } }, index: number) => void; + onRowClick: ( + event: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }, + index: number, + ) => void; + onRowFocus: (event: unknown, index: number) => void; + onTableBlur: () => void; + onTableFocus: () => void; + } + >; + + return ( + + + + ); + } + }; +} + +export default makeSelectable; diff --git a/src/components/table/messages.js b/src/components/table/messages.js.flow similarity index 100% rename from src/components/table/messages.js rename to src/components/table/messages.js.flow diff --git a/src/components/table/messages.ts b/src/components/table/messages.ts new file mode 100644 index 0000000000..0664269d6a --- /dev/null +++ b/src/components/table/messages.ts @@ -0,0 +1,41 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + downDescription: { + defaultMessage: 'Select next item', + description: 'Description for keyboard shortcut to select next item in the file list', + id: 'boxui.core.selection.downDescription', + }, + upDescription: { + defaultMessage: 'Select previous item', + description: 'Description for keyboard shortcut to select previous item in the file list', + id: 'boxui.core.selection.upDescription', + }, + shiftXDescription: { + defaultMessage: 'Select current item', + description: 'Description for keyboard shortcut to select previous item in the file list', + id: 'boxui.core.selection.shiftXDescription', + }, + shiftUpDescription: { + defaultMessage: 'Add previous item to current selection', + description: 'Description for keyboard shortcut to add previous item to current selection in the file list', + id: 'boxui.core.selection.shiftUpDescription', + }, + shiftDownDescription: { + defaultMessage: 'Add next item to current selection', + description: 'Description for keyboard shortcut to add next item to current selection in the file list', + id: 'boxui.core.selection.shiftDownDescription', + }, + selectAllDescription: { + defaultMessage: 'Select all items', + description: 'Description for keyboard shortcut to select all items in the file list', + id: 'boxui.core.selection.selectAllDescription', + }, + deselectAllDescription: { + defaultMessage: 'Deselect all items', + description: 'Description for keyboard shortcut to deselect all items in the file list', + id: 'boxui.core.selection.deselectAllDescription', + }, +}); + +export default messages; diff --git a/src/components/table/shiftSelect.js b/src/components/table/shiftSelect.js.flow similarity index 100% rename from src/components/table/shiftSelect.js rename to src/components/table/shiftSelect.js.flow diff --git a/src/components/table/shiftSelect.ts b/src/components/table/shiftSelect.ts new file mode 100644 index 0000000000..1129c779ca --- /dev/null +++ b/src/components/table/shiftSelect.ts @@ -0,0 +1,59 @@ +import { Range, Set } from 'immutable'; + +/** + * Computes the selection when shift-selecting rows. + * + * When doing ranges, we may unselect items that were selected in a previous range selection + * There are 6 cases to handle: + * [PrevTarget, Anchor, Target] + * [PrevTarget, Target, Anchor] + * [Anchor, PrevTarget, Target] + * [Anchor, Target, PrevTarget] + * [Target, Anchor, PrevTarget] + * [Target, PrevTarget, Anchor] + * + * @param {Set} prevSelection + * @param {Number} prevTarget + * @param {Number} target + * @param {Number} anchor + * @return {Set} + */ +function shiftSelect(prevSelection: Set, prevTarget: number, target: number, anchor: number): Set { + if (prevTarget <= anchor && anchor <= target) { + // [PrevTarget, Anchor, Target] + return prevSelection.subtract(Range(prevTarget, anchor + 1)).union(Range(anchor, target + 1)); + } + + if (prevTarget <= target && target <= anchor) { + // [PrevTarget, Target, Anchor] + return prevSelection.subtract(Range(prevTarget, target + 1)).union(Range(target, anchor + 1)); + } + + if (anchor <= prevTarget && prevTarget <= target) { + // [Anchor, PrevTarget, Target] + return prevSelection.union(Range(anchor, target + 1)); + } + + if (anchor <= target && target <= prevTarget) { + // [Anchor, Target, PrevTarget] + return prevSelection.subtract(Range(target, prevTarget + 1)).union(Range(anchor, target + 1)); + } + + if (target <= anchor && anchor <= prevTarget) { + // [Target, Anchor, PrevTarget] + return prevSelection.subtract(Range(anchor, prevTarget + 1)).union(Range(target, anchor + 1)); + } + + if (target <= prevTarget && target <= anchor) { + // [Target, PrevTarget, Anchor] + return prevSelection.union(Range(target, anchor + 1)); + } + + throw new Error( + `Invalid shiftSelect params: [${Array.prototype.slice.call( + arguments, // eslint-disable-line prefer-rest-params + )}]`, + ); +} + +export default shiftSelect;