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) {
if (this.reverse !== reverse) {
this.reverse = reverse;
// TODO: not needed anymore
this.rebuildOutput();
}
}

View File

@@ -29,6 +29,7 @@ export type ColumnFilterHandlers = {
onAddColumnFilter(columnId: string, value: string): void;
onRemoveColumnFilter(columnId: string, index: number): void;
onToggleColumnFilter(columnId: string, index: number): void;
onSetColumnFilterFromSelection(columnId: string): void;
};
export function FilterIcon({
@@ -98,6 +99,37 @@ export function FilterIcon({
No active filters
</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>
);

View File

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

View File

@@ -8,26 +8,16 @@
*/
import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
import {Menu} from 'antd';
import {DataTableColumn} from './DataTable';
import {Checkbox, Menu} from 'antd';
import {TableManager} from './useDataTableManager';
import React from 'react';
import {createContext} from 'react';
import {normalizeCellValue} from './TableRow';
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
import {DataTableColumn} from './DataTable';
const {Item, SubMenu} = Menu;
export const TableContextMenuContext = createContext<
React.ReactElement | undefined
>(undefined);
export function tableContextMenuFactory<T>(
visibleColumns: DataTableColumn<T>[],
addColumnFilter: TableManager['addColumnFilter'],
_getSelection: () => T,
getMultiSelection: () => T[],
) {
export function tableContextMenuFactory(tableManager: TableManager) {
const lib = tryGetFlipperLibImplementation();
if (!lib) {
return (
@@ -36,34 +26,32 @@ export function tableContextMenuFactory<T>(
</Menu>
);
}
const hasSelection = tableManager.selection?.items.size > 0 ?? false;
return (
<Menu>
<SubMenu title="Filter on same..." icon={<FilterOutlined />}>
{visibleColumns.map((column) => (
<SubMenu
title="Filter on same"
icon={<FilterOutlined />}
disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
const items = getMultiSelection();
if (items.length) {
items.forEach((item, index) => {
addColumnFilter(
column.key,
normalizeCellValue(item[column.key]),
index === 0, // remove existing filters before adding the first
);
});
}
tableManager.setColumnFilterFromSelection(column.key);
}}>
{column.title || column.key}
{friendlyColumnTitle(column)}
</Item>
))}
</SubMenu>
<SubMenu title="Copy cell(s)" icon={<CopyOutlined />}>
{visibleColumns.map((column) => (
<SubMenu
title="Copy cell(s)"
icon={<CopyOutlined />}
disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
const items = getMultiSelection();
const items = tableManager.getSelectedItems();
if (items.length) {
lib.writeTextToClipboard(
items
@@ -72,13 +60,14 @@ export function tableContextMenuFactory<T>(
);
}
}}>
{column.title || column.key}
{friendlyColumnTitle(column)}
</Item>
))}
</SubMenu>
<Item
disabled={!hasSelection}
onClick={() => {
const items = getMultiSelection();
const items = tableManager.getSelectedItems();
if (items.length) {
lib.writeTextToClipboard(
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
@@ -89,8 +78,9 @@ export function tableContextMenuFactory<T>(
</Item>
{lib.isFB && (
<Item
disabled={!hasSelection}
onClick={() => {
const items = getMultiSelection();
const items = tableManager.getSelectedItems();
if (items.length) {
lib.createPaste(
JSON.stringify(items.length > 1 ? items : items[0], null, 2),
@@ -100,6 +90,30 @@ export function tableContextMenuFactory<T>(
Create paste
</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>
);
}
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 type {DataTableColumn} from './DataTable';
import {Checkbox, Dropdown, Menu, Typography} from 'antd';
import {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons';
import {Typography} from 'antd';
import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
import {Layout} from '../Layout';
import {Sorting, OnColumnResize} from './useDataTableManager';
import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager';
import {ColumnFilterHandlers, FilterIcon, HeaderButton} from './ColumnFilter';
const {Text} = Typography;
function SortIcons({direction}: {direction?: 'up' | 'down'}) {
function SortIcons({
direction,
onSort,
}: {
direction?: SortDirection;
onSort(direction: SortDirection): void;
}) {
return (
<SortIconsContainer direction={direction}>
<CaretUpFilled
onClick={(e) => {
e.stopPropagation();
onSort(direction === 'up' ? undefined : 'up');
}}
className={
'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '')
}
/>
<CaretDownFilled
onClick={(e) => {
e.stopPropagation();
onSort(direction === 'down' ? undefined : 'down');
}}
className={
'ant-table-column-sorter-down ' +
(direction === 'down' ? 'active' : '')
@@ -55,25 +69,41 @@ const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
position: 'relative',
left: 4,
top: -3,
cursor: 'pointer',
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>({
overflow: 'hidden',
whiteSpace: 'nowrap',
width: '100%',
borderRight: `1px solid ${theme.dividerColor}`,
paddingRight: 4,
});
TableHeaderColumnInteractive.displayName =
'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',
display: 'flex',
flexDirection: 'row',
@@ -84,25 +114,6 @@ const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
});
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};
function TableHeadColumn({
@@ -116,7 +127,7 @@ function TableHeadColumn({
column: DataTableColumn<any>;
sorted: 'up' | 'down' | undefined;
isResizable: boolean;
onSort: (id: string) => void;
onSort: (id: string, direction: SortDirection) => void;
sortOrder: undefined | Sorting;
onColumnResize: OnColumnResize;
} & ColumnFilterHandlers) {
@@ -150,11 +161,23 @@ function TableHeadColumn({
};
let children = (
<Layout.Right center style={{padding: '0 4px'}}>
<div onClick={() => onSort(column.key)} role="button" tabIndex={0}>
<Layout.Right center>
<div
onClick={(e) => {
e.stopPropagation();
onSort(
column.key,
sorted === 'up' ? undefined : sorted === 'down' ? 'up' : 'down',
);
}}
role="button"
tabIndex={0}>
<Text strong>
{column.title ?? <>&nbsp;</>}
<SortIcons direction={sorted} />
<SortIcons
direction={sorted}
onSort={(dir) => onSort(column.key, dir)}
/>
</Text>
</div>
<FilterIcon column={column} {...filterHandlers} />
@@ -181,40 +204,15 @@ function TableHeadColumn({
}
export const TableHead = memo(function TableHead({
columns,
visibleColumns,
...props
}: {
columns: DataTableColumn<any>[];
visibleColumns: DataTableColumn<any>[];
onColumnResize: OnColumnResize;
onColumnToggleVisibility: (key: string) => void;
onReset: () => void;
sorting: Sorting | undefined;
onColumnSort: (key: string) => void;
onColumnSort: (key: string, direction: SortDirection) => void;
} & 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 (
<TableHeadContainer>
{visibleColumns.map((column, i) => (
@@ -228,6 +226,7 @@ export const TableHead = memo(function TableHead({
onAddColumnFilter={props.onAddColumnFilter}
onRemoveColumnFilter={props.onRemoveColumnFilter}
onToggleColumnFilter={props.onToggleColumnFilter}
onSetColumnFilterFromSelection={props.onSetColumnFilterFromSelection}
sorted={
props.sorting?.key === column.key
? 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>
);
});

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,34 @@ test('computeSetSelection', () => {
current: 5,
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', () => {
@@ -79,7 +107,7 @@ test('computeAddRangeToSelection', () => {
// invest selection - toggle off
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]),
});

View File

@@ -17,9 +17,11 @@ import {useMemoize} from '../../utils/useMemoize';
export type OnColumnResize = (id: string, size: number | Percentage) => void;
export type Sorting = {
key: string;
direction: 'up' | 'down';
direction: Exclude<SortDirection, undefined>;
};
export type SortDirection = 'up' | 'down' | undefined;
export type TableManager = ReturnType<typeof useDataTableManager>;
type Selection = {items: ReadonlySet<number>; current: number};
@@ -53,6 +55,69 @@ export function useDataTableManager<T>(
[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(
(columnId: string, value: string, disableOthers = false) => {
// 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
const currentFilter = useMemoize(
computeDataTableFilter,
@@ -127,23 +208,22 @@ export function useDataTableManager<T>(
}, []);
const sortColumn = useCallback(
(key: string) => {
if (sorting?.key === key) {
if (sorting.direction === 'down') {
setSorting({key, direction: 'up'});
dataSource.setReversed(true);
} else {
(key: string, direction: SortDirection) => {
if (direction === undefined) {
// remove sorting
setSorting(undefined);
dataSource.setSortBy(undefined);
dataSource.setReversed(false);
}
} else {
setSorting({
key,
direction: 'down',
});
// update sorting
// TODO: make sure that setting both doesn't rebuild output twice!
if (!sorting || sorting.key !== key) {
dataSource.setSortBy(key as any);
dataSource.setReversed(false);
}
if (!sorting || sorting.direction !== direction) {
dataSource.setReversed(direction === 'up');
}
setSorting({key, direction});
}
},
[dataSource, sorting],
@@ -166,65 +246,6 @@ export function useDataTableManager<T>(
[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 {
/** The default columns, but normalized */
columns,
@@ -252,6 +273,7 @@ export function useDataTableManager<T>(
addColumnFilter,
removeColumnFilter,
toggleColumnFilter,
setColumnFilterFromSelection,
};
}
@@ -310,6 +332,10 @@ export function computeSetSelection(
): Selection {
const newIndex =
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) {
return emptySelection;
}
@@ -334,17 +360,18 @@ export function computeAddRangeToSelection(
end: number,
allowUnselect?: boolean,
): Selection {
// special case: unselectiong a single existing item
// special case: unselectiong a single item with the selection
if (start === end && allowUnselect) {
if (base?.items.has(start)) {
const copy = new Set(base.items);
copy.delete(start);
if (copy.size === 0) {
const current = [...copy];
if (current.length === 0) {
return emptySelection;
}
return {
items: copy,
current: start,
current: current[current.length - 1], // back to the last selected one
};
}
// intentional fall-through