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
@@ -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 {
|
||||
setSorting(undefined);
|
||||
dataSource.setSortBy(undefined);
|
||||
dataSource.setReversed(false);
|
||||
}
|
||||
} else {
|
||||
setSorting({
|
||||
key,
|
||||
direction: 'down',
|
||||
});
|
||||
dataSource.setSortBy(key as any);
|
||||
(key: string, direction: SortDirection) => {
|
||||
if (direction === undefined) {
|
||||
// remove sorting
|
||||
setSorting(undefined);
|
||||
dataSource.setSortBy(undefined);
|
||||
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],
|
||||
@@ -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