User experience improvements

Summary:
This diff has some jak-shaving UX improvements after playing with the DataTable a bit more:

Selection
* deselecting a row from a larger set will make the last selected item the default selection
* re-selecting an item in a single selection will unselect it

Column Filtering
* Introduced button to toggle between filtering on all, nothing, and the values present in the current selection

Column sorting
* The up / down arrows are now inidividually clickable, rather than action as a general toggle
* Title still works as a general toggle between asc / desc / not sorted

Context menu
* I found the context menu for column selection and on the selected rows itself a bit finicky to find and click and not super intuitive for noob users. Merged both menus instead into a single hamburger menu adjacent to the search bar

Reviewed By: passy

Differential Revision: D26580038

fbshipit-source-id: 220f501a1d996acbd51088c08ea866caed768572
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 59a1327261
commit 59e6c98669
10 changed files with 309 additions and 236 deletions

View File

@@ -299,6 +299,7 @@ export class DataSource<
setReversed(reverse: boolean) { setReversed(reverse: boolean) {
if (this.reverse !== reverse) { if (this.reverse !== reverse) {
this.reverse = reverse; this.reverse = reverse;
// TODO: not needed anymore
this.rebuildOutput(); this.rebuildOutput();
} }
} }

View File

@@ -29,6 +29,7 @@ export type ColumnFilterHandlers = {
onAddColumnFilter(columnId: string, value: string): void; onAddColumnFilter(columnId: string, value: string): void;
onRemoveColumnFilter(columnId: string, index: number): void; onRemoveColumnFilter(columnId: string, index: number): void;
onToggleColumnFilter(columnId: string, index: number): void; onToggleColumnFilter(columnId: string, index: number): void;
onSetColumnFilterFromSelection(columnId: string): void;
}; };
export function FilterIcon({ export function FilterIcon({
@@ -98,6 +99,37 @@ export function FilterIcon({
No active filters No active filters
</Text> </Text>
)} )}
<Menu.Divider />
<div style={{textAlign: 'right'}}>
<Button
type="link"
style={{fontWeight: 'unset'}}
onClick={() => {
props.onSetColumnFilterFromSelection(column.key);
}}>
From selection
</Button>
<Button
type="link"
style={{fontWeight: 'unset'}}
onClick={() => {
filters?.forEach((f, index) => {
if (!f.enabled) props.onToggleColumnFilter(column.key, index);
});
}}>
All
</Button>
<Button
type="link"
style={{fontWeight: 'unset'}}
onClick={() => {
filters?.forEach((f, index) => {
if (f.enabled) props.onToggleColumnFilter(column.key, index);
});
}}>
None
</Button>
</div>
</Menu> </Menu>
); );

View File

@@ -26,11 +26,7 @@ import {useDataTableManager, TableManager} from './useDataTableManager';
import {TableSearch} from './TableSearch'; import {TableSearch} from './TableSearch';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {theme} from '../theme'; import {theme} from '../theme';
import { import {tableContextMenuFactory} from './TableContextMenu';
tableContextMenuFactory,
TableContextMenuContext,
} from './TableContextMenu';
import {useMemoize} from '../../utils/useMemoize';
interface DataTableProps<T = any> { interface DataTableProps<T = any> {
columns: DataTableColumn<T>[]; columns: DataTableColumn<T>[];
@@ -93,9 +89,6 @@ export function DataTable<T extends object>(
selectItem, selectItem,
selection, selection,
addRangeToSelection, addRangeToSelection,
addColumnFilter,
getSelectedItem,
getSelectedItems,
} = tableManager; } = tableManager;
const renderingConfig = useMemo<RenderContext<T>>(() => { const renderingConfig = useMemo<RenderContext<T>>(() => {
@@ -236,13 +229,8 @@ export function DataTable<T extends object>(
// TODO: support customizing context menu // TODO: support customizing context menu
const contexMenu = props._testHeight const contexMenu = props._testHeight
? undefined // don't render context menu in tests ? undefined // don't render context menu in tests
: // eslint-disable-next-line : tableContextMenuFactory(tableManager);
useMemoize(tableContextMenuFactory, [
visibleColumns,
addColumnFilter,
getSelectedItem,
getSelectedItems as any,
]);
return ( return (
<Layout.Container grow> <Layout.Container grow>
<Layout.Top> <Layout.Top>
@@ -250,34 +238,34 @@ export function DataTable<T extends object>(
<TableSearch <TableSearch
onSearch={tableManager.setSearchValue} onSearch={tableManager.setSearchValue}
extraActions={props.extraActions} extraActions={props.extraActions}
contextMenu={contexMenu}
/> />
<TableHead <TableHead
columns={tableManager.columns}
visibleColumns={tableManager.visibleColumns} visibleColumns={tableManager.visibleColumns}
onColumnResize={tableManager.resizeColumn} onColumnResize={tableManager.resizeColumn}
onReset={tableManager.reset} onReset={tableManager.reset}
onColumnToggleVisibility={tableManager.toggleColumnVisibility}
sorting={tableManager.sorting} sorting={tableManager.sorting}
onColumnSort={tableManager.sortColumn} onColumnSort={tableManager.sortColumn}
onAddColumnFilter={tableManager.addColumnFilter} onAddColumnFilter={tableManager.addColumnFilter}
onRemoveColumnFilter={tableManager.removeColumnFilter} onRemoveColumnFilter={tableManager.removeColumnFilter}
onToggleColumnFilter={tableManager.toggleColumnFilter} onToggleColumnFilter={tableManager.toggleColumnFilter}
onSetColumnFilterFromSelection={
tableManager.setColumnFilterFromSelection
}
/> />
</Layout.Container> </Layout.Container>
<TableContextMenuContext.Provider value={contexMenu}> <DataSourceRenderer<T, RenderContext<T>>
<DataSourceRenderer<T, RenderContext<T>> dataSource={dataSource}
dataSource={dataSource} autoScroll={props.autoScroll}
autoScroll={props.autoScroll} useFixedRowHeight={!usesWrapping}
useFixedRowHeight={!usesWrapping} defaultRowHeight={DEFAULT_ROW_HEIGHT}
defaultRowHeight={DEFAULT_ROW_HEIGHT} context={renderingConfig}
context={renderingConfig} itemRenderer={itemRenderer}
itemRenderer={itemRenderer} onKeyDown={onKeyDown}
onKeyDown={onKeyDown} virtualizerRef={virtualizerRef}
virtualizerRef={virtualizerRef} onRangeChange={onRangeChange}
onRangeChange={onRangeChange} _testHeight={props._testHeight}
_testHeight={props._testHeight} />
/>
</TableContextMenuContext.Provider>
</Layout.Top> </Layout.Top>
{range && <RangeFinder>{range}</RangeFinder>} {range && <RangeFinder>{range}</RangeFinder>}
</Layout.Container> </Layout.Container>

View File

@@ -8,26 +8,16 @@
*/ */
import {CopyOutlined, FilterOutlined} from '@ant-design/icons'; import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
import {Menu} from 'antd'; import {Checkbox, Menu} from 'antd';
import {DataTableColumn} from './DataTable';
import {TableManager} from './useDataTableManager'; import {TableManager} from './useDataTableManager';
import React from 'react'; import React from 'react';
import {createContext} from 'react';
import {normalizeCellValue} from './TableRow'; import {normalizeCellValue} from './TableRow';
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
import {DataTableColumn} from './DataTable';
const {Item, SubMenu} = Menu; const {Item, SubMenu} = Menu;
export const TableContextMenuContext = createContext< export function tableContextMenuFactory(tableManager: TableManager) {
React.ReactElement | undefined
>(undefined);
export function tableContextMenuFactory<T>(
visibleColumns: DataTableColumn<T>[],
addColumnFilter: TableManager['addColumnFilter'],
_getSelection: () => T,
getMultiSelection: () => T[],
) {
const lib = tryGetFlipperLibImplementation(); const lib = tryGetFlipperLibImplementation();
if (!lib) { if (!lib) {
return ( return (
@@ -36,34 +26,32 @@ export function tableContextMenuFactory<T>(
</Menu> </Menu>
); );
} }
const hasSelection = tableManager.selection?.items.size > 0 ?? false;
return ( return (
<Menu> <Menu>
<SubMenu title="Filter on same..." icon={<FilterOutlined />}> <SubMenu
{visibleColumns.map((column) => ( title="Filter on same"
icon={<FilterOutlined />}
disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => (
<Item <Item
key={column.key} key={column.key}
onClick={() => { onClick={() => {
const items = getMultiSelection(); tableManager.setColumnFilterFromSelection(column.key);
if (items.length) {
items.forEach((item, index) => {
addColumnFilter(
column.key,
normalizeCellValue(item[column.key]),
index === 0, // remove existing filters before adding the first
);
});
}
}}> }}>
{column.title || column.key} {friendlyColumnTitle(column)}
</Item> </Item>
))} ))}
</SubMenu> </SubMenu>
<SubMenu title="Copy cell(s)" icon={<CopyOutlined />}> <SubMenu
{visibleColumns.map((column) => ( title="Copy cell(s)"
icon={<CopyOutlined />}
disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => (
<Item <Item
key={column.key} key={column.key}
onClick={() => { onClick={() => {
const items = getMultiSelection(); const items = tableManager.getSelectedItems();
if (items.length) { if (items.length) {
lib.writeTextToClipboard( lib.writeTextToClipboard(
items items
@@ -72,13 +60,14 @@ export function tableContextMenuFactory<T>(
); );
} }
}}> }}>
{column.title || column.key} {friendlyColumnTitle(column)}
</Item> </Item>
))} ))}
</SubMenu> </SubMenu>
<Item <Item
disabled={!hasSelection}
onClick={() => { onClick={() => {
const items = getMultiSelection(); const items = tableManager.getSelectedItems();
if (items.length) { if (items.length) {
lib.writeTextToClipboard( lib.writeTextToClipboard(
JSON.stringify(items.length > 1 ? items : items[0], null, 2), JSON.stringify(items.length > 1 ? items : items[0], null, 2),
@@ -89,8 +78,9 @@ export function tableContextMenuFactory<T>(
</Item> </Item>
{lib.isFB && ( {lib.isFB && (
<Item <Item
disabled={!hasSelection}
onClick={() => { onClick={() => {
const items = getMultiSelection(); const items = tableManager.getSelectedItems();
if (items.length) { if (items.length) {
lib.createPaste( lib.createPaste(
JSON.stringify(items.length > 1 ? items : items[0], null, 2), JSON.stringify(items.length > 1 ? items : items[0], null, 2),
@@ -100,6 +90,30 @@ export function tableContextMenuFactory<T>(
Create paste Create paste
</Item> </Item>
)} )}
<Menu.Divider />
<SubMenu title="Visible columns">
{tableManager.columns.map((column) => (
<Menu.Item key={column.key}>
<Checkbox
checked={column.visible}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
tableManager.toggleColumnVisibility(column.key);
}}>
{friendlyColumnTitle(column)}
</Checkbox>
</Menu.Item>
))}
</SubMenu>
<Menu.Item key="reset" onClick={tableManager.reset}>
Reset view
</Menu.Item>
</Menu> </Menu>
); );
} }
function friendlyColumnTitle(column: DataTableColumn<any>): string {
const name = column.title || column.key;
return name[0].toUpperCase() + name.substr(1);
}

View File

@@ -20,23 +20,37 @@ import React from 'react';
import {theme} from '../theme'; import {theme} from '../theme';
import type {DataTableColumn} from './DataTable'; import type {DataTableColumn} from './DataTable';
import {Checkbox, Dropdown, Menu, Typography} from 'antd'; import {Typography} from 'antd';
import {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons'; import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {Sorting, OnColumnResize} from './useDataTableManager'; import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager';
import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter'; import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter';
const {Text} = Typography; const {Text} = Typography;
function SortIcons({direction}: {direction?: 'up' | 'down'}) { function SortIcons({
direction,
onSort,
}: {
direction?: SortDirection;
onSort(direction: SortDirection): void;
}) {
return ( return (
<SortIconsContainer direction={direction}> <SortIconsContainer direction={direction}>
<CaretUpFilled <CaretUpFilled
onClick={(e) => {
e.stopPropagation();
onSort(direction === 'up' ? undefined : 'up');
}}
className={ className={
'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '') 'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '')
} }
/> />
<CaretDownFilled <CaretDownFilled
onClick={(e) => {
e.stopPropagation();
onSort(direction === 'down' ? undefined : 'down');
}}
className={ className={
'ant-table-column-sorter-down ' + 'ant-table-column-sorter-down ' +
(direction === 'down' ? 'active' : '') (direction === 'down' ? 'active' : '')
@@ -55,25 +69,41 @@ const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
position: 'relative', position: 'relative',
left: 4, left: 4,
top: -3, top: -3,
cursor: 'pointer',
color: theme.disabledColor, color: theme.disabledColor,
'.ant-table-column-sorter-up:hover, .ant-table-column-sorter-down:hover': {
color: theme.primaryColor,
},
}), }),
); );
const SettingsButton = styled(HeaderButton)({
position: 'absolute',
right: 0,
top: 0,
});
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({ const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
overflow: 'hidden', overflow: 'hidden',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
width: '100%', width: '100%',
borderRight: `1px solid ${theme.dividerColor}`,
paddingRight: 4,
}); });
TableHeaderColumnInteractive.displayName = TableHeaderColumnInteractive.displayName =
'TableHead:TableHeaderColumnInteractive'; 'TableHead:TableHeaderColumnInteractive';
const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({ const TableHeadColumnContainer = styled.div<{
width: Width;
}>((props) => ({
flexShrink: props.width === undefined ? 1 : 0,
flexGrow: props.width === undefined ? 1 : 0,
width: props.width === undefined ? '100%' : props.width,
paddingLeft: 4,
[`:hover ${SortIconsContainer}`]: {
visibility: 'visible',
},
[`&:hover ${HeaderButton}`]: {
visibility: 'visible !important' as any,
},
}));
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
const TableHeadContainer = styled.div({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
@@ -84,25 +114,6 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
}); });
TableHeadContainer.displayName = 'TableHead:TableHeadContainer'; TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
const TableHeadColumnContainer = styled.div<{
width: Width;
}>((props) => ({
flexShrink: props.width === undefined ? 1 : 0,
flexGrow: props.width === undefined ? 1 : 0,
width: props.width === undefined ? '100%' : props.width,
'&:last-of-type': {
marginRight: 20, // space for settings button
},
[`:hover ${SortIconsContainer}`]: {
visibility: 'visible',
},
[`&:hover ${HeaderButton}`]: {
visibility: 'visible !important' as any,
},
padding: '0 4px',
}));
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
const RIGHT_RESIZABLE = {right: true}; const RIGHT_RESIZABLE = {right: true};
function TableHeadColumn({ function TableHeadColumn({
@@ -116,7 +127,7 @@ function TableHeadColumn({
column: DataTableColumn<any>; column: DataTableColumn<any>;
sorted: 'up' | 'down' | undefined; sorted: 'up' | 'down' | undefined;
isResizable: boolean; isResizable: boolean;
onSort: (id: string) => void; onSort: (id: string, direction: SortDirection) => void;
sortOrder: undefined | Sorting; sortOrder: undefined | Sorting;
onColumnResize: OnColumnResize; onColumnResize: OnColumnResize;
} & ColumnFilterHandlers) { } & ColumnFilterHandlers) {
@@ -150,11 +161,23 @@ function TableHeadColumn({
}; };
let children = ( let children = (
<Layout.Right center style={{padding: '0 4px'}}> <Layout.Right center>
<div onClick={() => onSort(column.key)} role="button" tabIndex={0}> <div
onClick={(e) => {
e.stopPropagation();
onSort(
column.key,
sorted === 'up' ? undefined : sorted === 'down' ? 'up' : 'down',
);
}}
role="button"
tabIndex={0}>
<Text strong> <Text strong>
{column.title ?? <>&nbsp;</>} {column.title ?? <>&nbsp;</>}
<SortIcons direction={sorted} /> <SortIcons
direction={sorted}
onSort={(dir) => onSort(column.key, dir)}
/>
</Text> </Text>
</div> </div>
<FilterIcon column={column} {...filterHandlers} /> <FilterIcon column={column} {...filterHandlers} />
@@ -181,40 +204,15 @@ function TableHeadColumn({
} }
export const TableHead = memo(function TableHead({ export const TableHead = memo(function TableHead({
columns,
visibleColumns, visibleColumns,
...props ...props
}: { }: {
columns: DataTableColumn<any>[];
visibleColumns: DataTableColumn<any>[]; visibleColumns: DataTableColumn<any>[];
onColumnResize: OnColumnResize; onColumnResize: OnColumnResize;
onColumnToggleVisibility: (key: string) => void;
onReset: () => void; onReset: () => void;
sorting: Sorting | undefined; sorting: Sorting | undefined;
onColumnSort: (key: string) => void; onColumnSort: (key: string, direction: SortDirection) => void;
} & ColumnFilterHandlers) { } & ColumnFilterHandlers) {
const menu = (
<Menu style={{minWidth: 200}}>
{columns.map((column) => (
<Menu.Item key={column.key}>
<Checkbox
checked={column.visible}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
props.onColumnToggleVisibility(column.key);
}}>
{column.title || column.key}
</Checkbox>
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item key="reset" onClick={props.onReset}>
Reset
</Menu.Item>
</Menu>
);
return ( return (
<TableHeadContainer> <TableHeadContainer>
{visibleColumns.map((column, i) => ( {visibleColumns.map((column, i) => (
@@ -228,6 +226,7 @@ export const TableHead = memo(function TableHead({
onAddColumnFilter={props.onAddColumnFilter} onAddColumnFilter={props.onAddColumnFilter}
onRemoveColumnFilter={props.onRemoveColumnFilter} onRemoveColumnFilter={props.onRemoveColumnFilter}
onToggleColumnFilter={props.onToggleColumnFilter} onToggleColumnFilter={props.onToggleColumnFilter}
onSetColumnFilterFromSelection={props.onSetColumnFilterFromSelection}
sorted={ sorted={
props.sorting?.key === column.key props.sorting?.key === column.key
? props.sorting!.direction ? props.sorting!.direction
@@ -235,11 +234,6 @@ export const TableHead = memo(function TableHead({
} }
/> />
))} ))}
<Dropdown overlay={menu} trigger={['click']}>
<SettingsButton type="text">
<DownOutlined />
</SettingsButton>
</Dropdown>
</TableHeadContainer> </TableHeadContainer>
); );
}); });

View File

@@ -7,15 +7,12 @@
* @format * @format
*/ */
import React, {memo, useContext} from 'react'; import React, {memo} from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {theme} from 'flipper-plugin'; import {theme} from 'flipper-plugin';
import type {RenderContext} from './DataTable'; import type {RenderContext} from './DataTable';
import {Width} from '../../utils/widthUtils'; import {Width} from '../../utils/widthUtils';
import {pad} from 'lodash'; import {pad} from 'lodash';
import {DownCircleFilled} from '@ant-design/icons';
import {Dropdown} from 'antd';
import {TableContextMenuContext} from './TableContextMenu';
// heuristic for row estimation, should match any future styling updates // heuristic for row estimation, should match any future styling updates
export const DEFAULT_ROW_HEIGHT = 24; export const DEFAULT_ROW_HEIGHT = 24;
@@ -85,8 +82,6 @@ const TableBodyColumnContainer = styled.div<{
})); }));
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer'; TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
const contextMenuTriggers = ['click' as const, 'contextMenu' as const];
type Props = { type Props = {
config: RenderContext<any>; config: RenderContext<any>;
highlighted: boolean; highlighted: boolean;
@@ -96,7 +91,6 @@ type Props = {
export const TableRow = memo(function TableRow(props: Props) { export const TableRow = memo(function TableRow(props: Props) {
const {config, highlighted, value: row} = props; const {config, highlighted, value: row} = props;
const menu = useContext(TableContextMenuContext);
return ( return (
<TableBodyRowContainer <TableBodyRowContainer
highlighted={highlighted} highlighted={highlighted}
@@ -125,26 +119,10 @@ export const TableRow = memo(function TableRow(props: Props) {
</TableBodyColumnContainer> </TableBodyColumnContainer>
); );
})} })}
{menu && highlighted && (
<RowContextMenuWrapper
onClick={stopPropagation}
onMouseDown={stopPropagation}>
<Dropdown
overlay={menu}
placement="bottomRight"
trigger={contextMenuTriggers}>
<DownCircleFilled />
</Dropdown>
</RowContextMenuWrapper>
)}
</TableBodyRowContainer> </TableBodyRowContainer>
); );
}); });
function stopPropagation(e: React.MouseEvent<any>) {
e.stopPropagation();
}
export function normalizeCellValue(value: any): string { export function normalizeCellValue(value: any): string {
switch (typeof value) { switch (typeof value) {
case 'boolean': case 'boolean':

View File

@@ -7,7 +7,8 @@
* @format * @format
*/ */
import {Input} from 'antd'; import {MenuOutlined} from '@ant-design/icons';
import {Button, Dropdown, Input} from 'antd';
import React, {memo} from 'react'; import React, {memo} from 'react';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {theme} from '../theme'; import {theme} from '../theme';
@@ -15,9 +16,12 @@ import {theme} from '../theme';
export const TableSearch = memo(function TableSearch({ export const TableSearch = memo(function TableSearch({
onSearch, onSearch,
extraActions, extraActions,
contextMenu,
}: { }: {
onSearch(value: string): void; onSearch(value: string): void;
extraActions?: React.ReactElement; extraActions?: React.ReactElement;
hasSelection?: boolean;
contextMenu?: React.ReactElement;
}) { }) {
return ( return (
<Layout.Horizontal <Layout.Horizontal
@@ -28,6 +32,13 @@ export const TableSearch = memo(function TableSearch({
}}> }}>
<Input.Search allowClear placeholder="Search..." onSearch={onSearch} /> <Input.Search allowClear placeholder="Search..." onSearch={onSearch} />
{extraActions} {extraActions}
{contextMenu && (
<Dropdown overlay={contextMenu} placement="bottomRight">
<Button type="text" size="small" style={{height: '100%'}}>
<MenuOutlined />
</Button>
</Dropdown>
)}
</Layout.Horizontal> </Layout.Horizontal>
); );
}); });

View File

@@ -195,7 +195,7 @@ test('sorting', async () => {
} }
// sort asc // sort asc
act(() => { act(() => {
ref.current?.sortColumn('title'); ref.current?.sortColumn('title', 'down');
}); });
{ {
const elem = await rendering.findAllByText(/item/); const elem = await rendering.findAllByText(/item/);
@@ -208,7 +208,7 @@ test('sorting', async () => {
} }
// sort desc // sort desc
act(() => { act(() => {
ref.current?.sortColumn('title'); ref.current?.sortColumn('title', 'up');
}); });
{ {
const elem = await rendering.findAllByText(/item/); const elem = await rendering.findAllByText(/item/);
@@ -219,9 +219,9 @@ test('sorting', async () => {
'item a', 'item a',
]); ]);
} }
// another click resets again // reset sort
act(() => { act(() => {
ref.current?.sortColumn('title'); ref.current?.sortColumn('title', undefined);
}); });
{ {
const elem = await rendering.findAllByText(/item/); const elem = await rendering.findAllByText(/item/);

View File

@@ -46,6 +46,34 @@ test('computeSetSelection', () => {
current: 5, current: 5,
items: new Set([2, 3, 8, 9, 5, 6, 7]), // n.b. order is irrelevant items: new Set([2, 3, 8, 9, 5, 6, 7]), // n.b. order is irrelevant
}); });
// single item existing selection
expect(
computeSetSelection(
{
current: 4,
items: new Set([4]),
},
5,
),
).toEqual({
current: 5,
items: new Set([5]),
});
// single item existing selection, toggle item off
expect(
computeSetSelection(
{
current: 4,
items: new Set([4]),
},
4,
),
).toEqual({
current: -1,
items: new Set(),
});
}); });
test('computeAddRangeToSelection', () => { test('computeAddRangeToSelection', () => {
@@ -79,7 +107,7 @@ test('computeAddRangeToSelection', () => {
// invest selection - toggle off // invest selection - toggle off
expect(computeAddRangeToSelection(partialBase, 8, 8, true)).toEqual({ expect(computeAddRangeToSelection(partialBase, 8, 8, true)).toEqual({
current: 8, // note: this item is not part of the selection! current: 9, // select the next thing
items: new Set([2, 3, 9]), items: new Set([2, 3, 9]),
}); });

View File

@@ -17,9 +17,11 @@ import {useMemoize} from '../../utils/useMemoize';
export type OnColumnResize = (id: string, size: number | Percentage) => void; export type OnColumnResize = (id: string, size: number | Percentage) => void;
export type Sorting = { export type Sorting = {
key: string; key: string;
direction: 'up' | 'down'; direction: Exclude<SortDirection, undefined>;
}; };
export type SortDirection = 'up' | 'down' | undefined;
export type TableManager = ReturnType<typeof useDataTableManager>; export type TableManager = ReturnType<typeof useDataTableManager>;
type Selection = {items: ReadonlySet<number>; current: number}; type Selection = {items: ReadonlySet<number>; current: number};
@@ -53,6 +55,69 @@ export function useDataTableManager<T>(
[columns], [columns],
); );
/**
* Select an individual item, used by mouse clicks and keyboard navigation
* Set addToSelection if the current selection should be expanded to the given position,
* rather than replacing the current selection.
*
* The nextIndex can be used to compute the new selection by basing relatively to the current selection
*/
const selectItem = useCallback(
(
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
) => {
setSelection((base) =>
computeSetSelection(base, nextIndex, addToSelection),
);
},
[],
);
/**
* Adds a range of items to the current seleciton (if any)
*/
const addRangeToSelection = useCallback(
(start: number, end: number, allowUnselect?: boolean) => {
setSelection((base) =>
computeAddRangeToSelection(base, start, end, allowUnselect),
);
},
[],
);
// N.B: we really want to have stable refs for these functions,
// to avoid that all context menus need re-render for every selection change,
// hence the selectionRef hack
const getSelectedItem = useCallback(() => {
return selectionRef.current.current < 0
? undefined
: dataSource.getItem(selectionRef.current.current);
}, [dataSource]);
const getSelectedItems = useCallback(() => {
return [...selectionRef.current.items]
.sort()
.map((i) => dataSource.getItem(i))
.filter(Boolean) as any[];
}, [dataSource]);
useEffect(
function fireSelection() {
if (onSelect) {
const item = getSelectedItem();
const items = getSelectedItems();
onSelect(item, items);
}
},
// selection is intentionally a dep
[onSelect, selection, selection, getSelectedItem, getSelectedItems],
);
/**
* Filtering
*/
const addColumnFilter = useCallback( const addColumnFilter = useCallback(
(columnId: string, value: string, disableOthers = false) => { (columnId: string, value: string, disableOthers = false) => {
// TODO: fix typings // TODO: fix typings
@@ -102,6 +167,22 @@ export function useDataTableManager<T>(
); );
}, []); }, []);
const setColumnFilterFromSelection = useCallback(
(columnId: string) => {
const items = getSelectedItems();
if (items.length) {
items.forEach((item, index) => {
addColumnFilter(
columnId,
item[columnId],
index === 0, // remove existing filters before adding the first
);
});
}
},
[getSelectedItems, addColumnFilter],
);
// filter is computed by useMemo to support adding column filters etc here in the future // filter is computed by useMemo to support adding column filters etc here in the future
const currentFilter = useMemoize( const currentFilter = useMemoize(
computeDataTableFilter, computeDataTableFilter,
@@ -127,23 +208,22 @@ export function useDataTableManager<T>(
}, []); }, []);
const sortColumn = useCallback( const sortColumn = useCallback(
(key: string) => { (key: string, direction: SortDirection) => {
if (sorting?.key === key) { if (direction === undefined) {
if (sorting.direction === 'down') { // remove sorting
setSorting({key, direction: 'up'}); setSorting(undefined);
dataSource.setReversed(true); dataSource.setSortBy(undefined);
} else {
setSorting(undefined);
dataSource.setSortBy(undefined);
dataSource.setReversed(false);
}
} else {
setSorting({
key,
direction: 'down',
});
dataSource.setSortBy(key as any);
dataSource.setReversed(false); dataSource.setReversed(false);
} else {
// update sorting
// TODO: make sure that setting both doesn't rebuild output twice!
if (!sorting || sorting.key !== key) {
dataSource.setSortBy(key as any);
}
if (!sorting || sorting.direction !== direction) {
dataSource.setReversed(direction === 'up');
}
setSorting({key, direction});
} }
}, },
[dataSource, sorting], [dataSource, sorting],
@@ -166,65 +246,6 @@ export function useDataTableManager<T>(
[currentFilter, dataSource], [currentFilter, dataSource],
); );
/**
* Select an individual item, used by mouse clicks and keyboard navigation
* Set addToSelection if the current selection should be expanded to the given position,
* rather than replacing the current selection.
*
* The nextIndex can be used to compute the new selection by basing relatively to the current selection
*/
const selectItem = useCallback(
(
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
) => {
setSelection((base) =>
computeSetSelection(base, nextIndex, addToSelection),
);
},
[],
);
/**
* Adds a range of items to the current seleciton (if any)
*/
const addRangeToSelection = useCallback(
(start: number, end: number, allowUnselect?: boolean) => {
setSelection((base) =>
computeAddRangeToSelection(base, start, end, allowUnselect),
);
},
[],
);
// N.B: we really want to have stable refs for these functions,
// to avoid that all context menus need re-render for every selection change,
// hence the selectionRef hack
const getSelectedItem = useCallback(() => {
return selectionRef.current.current < 0
? undefined
: dataSource.getItem(selectionRef.current.current);
}, [dataSource]);
const getSelectedItems = useCallback(() => {
return [...selectionRef.current.items]
.sort()
.map((i) => dataSource.getItem(i))
.filter(Boolean);
}, [dataSource]);
useEffect(
function fireSelection() {
if (onSelect) {
const item = getSelectedItem();
const items = getSelectedItems();
onSelect(item, items);
}
},
// selection is intentionally a dep
[onSelect, selection, selection, getSelectedItem, getSelectedItems],
);
return { return {
/** The default columns, but normalized */ /** The default columns, but normalized */
columns, columns,
@@ -252,6 +273,7 @@ export function useDataTableManager<T>(
addColumnFilter, addColumnFilter,
removeColumnFilter, removeColumnFilter,
toggleColumnFilter, toggleColumnFilter,
setColumnFilterFromSelection,
}; };
} }
@@ -310,6 +332,10 @@ export function computeSetSelection(
): Selection { ): Selection {
const newIndex = const newIndex =
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current); typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
// special case: toggle existing selection off
if (!addToSelection && base.items.size === 1 && base.current === newIndex) {
return emptySelection;
}
if (newIndex < 0) { if (newIndex < 0) {
return emptySelection; return emptySelection;
} }
@@ -334,17 +360,18 @@ export function computeAddRangeToSelection(
end: number, end: number,
allowUnselect?: boolean, allowUnselect?: boolean,
): Selection { ): Selection {
// special case: unselectiong a single existing item // special case: unselectiong a single item with the selection
if (start === end && allowUnselect) { if (start === end && allowUnselect) {
if (base?.items.has(start)) { if (base?.items.has(start)) {
const copy = new Set(base.items); const copy = new Set(base.items);
copy.delete(start); copy.delete(start);
if (copy.size === 0) { const current = [...copy];
if (current.length === 0) {
return emptySelection; return emptySelection;
} }
return { return {
items: copy, items: copy,
current: start, current: current[current.length - 1], // back to the last selected one
}; };
} }
// intentional fall-through // intentional fall-through