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:
committed by
Facebook GitHub Bot
parent
59a1327261
commit
59e6c98669
@@ -299,6 +299,7 @@ export class DataSource<
|
||||
setReversed(reverse: boolean) {
|
||||
if (this.reverse !== reverse) {
|
||||
this.reverse = reverse;
|
||||
// TODO: not needed anymore
|
||||
this.rebuildOutput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ?? <> </>}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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]),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user