Added support for dotted key paths in Data table column
Summary: This adds support for the key of DataTableColumn to be a dotted path into a nested object, e.g foo.bar. Currently the typescript types only allow a top level key to be set, making this feature currently unusuable from plugin code. While this could be addressed in a future commit the intention of this is to allow the user to add custom fields into their table columns at run time Note there is a side effect to free text search from this commit. Previously it would search all top keys in the object. Now it will only search in columns that are in the table. changelog: Searching data table will now only search columns in the table, rather than all top level attributes of the object Reviewed By: mweststrate Differential Revision: D36663929 fbshipit-source-id: 3688e9f26aa7e1828f8e9ee69f8e6f86268c8a54
This commit is contained in:
committed by
Facebook GitHub Bot
parent
8c4b494d32
commit
e07d5c5bfe
@@ -94,6 +94,7 @@ type DataTableInput<T = any> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DataTableColumn<T = any> = {
|
export type DataTableColumn<T = any> = {
|
||||||
|
//this can be a dotted path into a nest objects. e.g foo.bar
|
||||||
key: keyof T & string;
|
key: keyof T & string;
|
||||||
// possible future extension: getValue(row) (and free-form key) to support computed columns
|
// possible future extension: getValue(row) (and free-form key) to support computed columns
|
||||||
onRender?: (row: T, selected: boolean, index: number) => React.ReactNode;
|
onRender?: (row: T, selected: boolean, index: number) => React.ReactNode;
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export const dataTableManagerReducer = produce<
|
|||||||
addColumnFilter(
|
addColumnFilter(
|
||||||
draft.columns,
|
draft.columns,
|
||||||
action.column,
|
action.column,
|
||||||
(item as any)[action.column],
|
getValueAtPath(item, String(action.column)),
|
||||||
index === 0, // remove existing filters before adding the first
|
index === 0, // remove existing filters before adding the first
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -541,6 +541,25 @@ function computeInitialColumns(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A somewhat primitive and unsafe way to access nested fields an object.
|
||||||
|
* @param obj keys should only be strings
|
||||||
|
* @param keyPath dotted string path, e.g foo.bar
|
||||||
|
* @returns value at the key path
|
||||||
|
*/
|
||||||
|
export function getValueAtPath(obj: any, keyPath: string): any {
|
||||||
|
let res = obj;
|
||||||
|
for (const key of keyPath.split('.')) {
|
||||||
|
if (res == null) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
res = res[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
export function computeDataTableFilter(
|
export function computeDataTableFilter(
|
||||||
searchValue: string,
|
searchValue: string,
|
||||||
useRegex: boolean,
|
useRegex: boolean,
|
||||||
@@ -563,7 +582,10 @@ export function computeDataTableFilter(
|
|||||||
for (const column of filteringColumns) {
|
for (const column of filteringColumns) {
|
||||||
const rowMatchesFilter = column.filters!.some(
|
const rowMatchesFilter = column.filters!.some(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.enabled && String(item[column.key]).toLowerCase().includes(f.value),
|
f.enabled &&
|
||||||
|
String(getValueAtPath(item, column.key))
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(f.value),
|
||||||
);
|
);
|
||||||
if (column.inversed && rowMatchesFilter) {
|
if (column.inversed && rowMatchesFilter) {
|
||||||
return false;
|
return false;
|
||||||
@@ -572,11 +594,14 @@ export function computeDataTableFilter(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Object.values(item).some((v) =>
|
|
||||||
searchRegex
|
return columns
|
||||||
? searchRegex.test(String(v))
|
.map((c) => getValueAtPath(item, c.key))
|
||||||
: String(v).toLowerCase().includes(searchString),
|
.some((v) =>
|
||||||
);
|
searchRegex
|
||||||
|
? searchRegex.test(String(v))
|
||||||
|
: String(v).toLowerCase().includes(searchString),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DataTableDispatch,
|
DataTableDispatch,
|
||||||
getSelectedItem,
|
getSelectedItem,
|
||||||
getSelectedItems,
|
getSelectedItems,
|
||||||
|
getValueAtPath,
|
||||||
Selection,
|
Selection,
|
||||||
} from './DataTableManager';
|
} from './DataTableManager';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -136,7 +137,9 @@ export function tableContextMenuFactory<T>(
|
|||||||
const items = getSelectedItems(datasource, selection);
|
const items = getSelectedItems(datasource, selection);
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
lib.writeTextToClipboard(
|
lib.writeTextToClipboard(
|
||||||
items.map((item) => '' + item[column.key]).join('\n'),
|
items
|
||||||
|
.map((item) => '' + getValueAtPath(item, column.key))
|
||||||
|
.join('\n'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {Width} from '../../utils/widthUtils';
|
|||||||
import {DataFormatter} from '../DataFormatter';
|
import {DataFormatter} from '../DataFormatter';
|
||||||
import {Dropdown} from 'antd';
|
import {Dropdown} from 'antd';
|
||||||
import {contextMenuTrigger} from '../data-inspector/DataInspectorNode';
|
import {contextMenuTrigger} from '../data-inspector/DataInspectorNode';
|
||||||
|
import {getValueAtPath} from './DataTableManager';
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -159,5 +160,5 @@ export function renderColumnValue<T>(
|
|||||||
) {
|
) {
|
||||||
return col.onRender
|
return col.onRender
|
||||||
? col.onRender(record, highlighted, itemIndex)
|
? col.onRender(record, highlighted, itemIndex)
|
||||||
: DataFormatter.format((record as any)[col.key], col.formatters);
|
: DataFormatter.format(getValueAtPath(record, col.key), col.formatters);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,23 +50,23 @@ test('update and append', async () => {
|
|||||||
const elem = await rendering.findAllByText('test DataTable');
|
const elem = await rendering.findAllByText('test DataTable');
|
||||||
expect(elem.length).toBe(1);
|
expect(elem.length).toBe(1);
|
||||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||||
<div
|
<div
|
||||||
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
||||||
width="50%"
|
width="50%"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
||||||
width="50%"
|
width="50%"
|
||||||
>
|
>
|
||||||
true
|
true
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -104,23 +104,23 @@ test('column visibility', async () => {
|
|||||||
const elem = await rendering.findAllByText('test DataTable');
|
const elem = await rendering.findAllByText('test DataTable');
|
||||||
expect(elem.length).toBe(1);
|
expect(elem.length).toBe(1);
|
||||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||||
<div
|
<div
|
||||||
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
||||||
width="50%"
|
width="50%"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
||||||
width="50%"
|
width="50%"
|
||||||
>
|
>
|
||||||
true
|
true
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// hide done
|
// hide done
|
||||||
@@ -131,17 +131,17 @@ test('column visibility', async () => {
|
|||||||
const elem = await rendering.findAllByText('test DataTable');
|
const elem = await rendering.findAllByText('test DataTable');
|
||||||
expect(elem.length).toBe(1);
|
expect(elem.length).toBe(1);
|
||||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||||
<div
|
<div
|
||||||
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
|
||||||
width="50%"
|
width="50%"
|
||||||
>
|
>
|
||||||
test DataTable
|
test DataTable
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset
|
// reset
|
||||||
@@ -285,15 +285,26 @@ test('search', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('compute filters', () => {
|
test('compute filters', () => {
|
||||||
|
const levelCol = {key: 'level'};
|
||||||
|
const titleCol = {key: 'title'};
|
||||||
|
const doneCol = {key: 'done'};
|
||||||
|
const baseColumns = [levelCol, titleCol, doneCol];
|
||||||
|
|
||||||
const coffee = {
|
const coffee = {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
title: 'Drink coffee',
|
title: 'Drink coffee',
|
||||||
done: true,
|
done: true,
|
||||||
|
extras: {
|
||||||
|
comment: 'tasty',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const espresso = {
|
const espresso = {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
title: 'Make espresso',
|
title: 'Make espresso',
|
||||||
done: false,
|
done: false,
|
||||||
|
extras: {
|
||||||
|
comment: 'dull',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const meet = {
|
const meet = {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
@@ -320,52 +331,64 @@ test('compute filters', () => {
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('tEsT', false, [])!;
|
const filter = computeDataTableFilter('tEsT', false, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([]);
|
expect(data.filter(filter)).toEqual([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('EE', false, [])!;
|
const filter = computeDataTableFilter('EE', false, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([coffee, meet]);
|
expect(data.filter(filter)).toEqual([coffee, meet]);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('D', false, [])!;
|
const filter = computeDataTableFilter('D', false, baseColumns)!;
|
||||||
|
expect(data.filter(filter)).toEqual([coffee]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentCol = {key: 'extras.comment'};
|
||||||
|
{
|
||||||
|
// free search on value tasty in nested column
|
||||||
|
const filter = computeDataTableFilter('tasty', false, [
|
||||||
|
...baseColumns,
|
||||||
|
commentCol,
|
||||||
|
])!;
|
||||||
expect(data.filter(filter)).toEqual([coffee]);
|
expect(data.filter(filter)).toEqual([coffee]);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// regex, positive (mind the double escaping of \\b)
|
// regex, positive (mind the double escaping of \\b)
|
||||||
const filter = computeDataTableFilter('..t', true, [])!;
|
const filter = computeDataTableFilter('..t', true, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([meet]);
|
expect(data.filter(filter)).toEqual([meet]);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// regex, words with 6 chars
|
// regex, words with 6 chars
|
||||||
const filter = computeDataTableFilter('\\w{6}', true, [])!;
|
const filter = computeDataTableFilter('\\w{6}', true, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([coffee, espresso]);
|
expect(data.filter(filter)).toEqual([coffee, espresso]);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// no match
|
// no match
|
||||||
const filter = computeDataTableFilter('\\w{18}', true, [])!;
|
const filter = computeDataTableFilter('\\w{18}', true, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([]);
|
expect(data.filter(filter)).toEqual([]);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// invalid regex
|
// invalid regex
|
||||||
const filter = computeDataTableFilter('bla/[', true, [])!;
|
const filter = computeDataTableFilter('bla/[', true, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([]);
|
expect(data.filter(filter)).toEqual([]);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('true', false, [])!;
|
const filter = computeDataTableFilter('true', false, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([coffee]);
|
expect(data.filter(filter)).toEqual([coffee]);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('false', false, [])!;
|
const filter = computeDataTableFilter('false', false, baseColumns)!;
|
||||||
expect(data.filter(filter)).toEqual([espresso, meet]);
|
expect(data.filter(filter)).toEqual([espresso, meet]);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('EE', false, [
|
const filter = computeDataTableFilter('EE', false, [
|
||||||
|
levelCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -381,6 +404,8 @@ test('compute filters', () => {
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('EE', false, [
|
const filter = computeDataTableFilter('EE', false, [
|
||||||
|
doneCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -401,6 +426,8 @@ test('compute filters', () => {
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('', false, [
|
const filter = computeDataTableFilter('', false, [
|
||||||
|
doneCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -421,6 +448,8 @@ test('compute filters', () => {
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('', false, [
|
const filter = computeDataTableFilter('', false, [
|
||||||
|
levelCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'done',
|
key: 'done',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -437,6 +466,8 @@ test('compute filters', () => {
|
|||||||
{
|
{
|
||||||
// nothing selected anything will not filter anything out for that column
|
// nothing selected anything will not filter anything out for that column
|
||||||
const filter = computeDataTableFilter('', false, [
|
const filter = computeDataTableFilter('', false, [
|
||||||
|
doneCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -455,8 +486,30 @@ test('compute filters', () => {
|
|||||||
])!;
|
])!;
|
||||||
expect(filter).toBeUndefined();
|
expect(filter).toBeUndefined();
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
//nested filter on comment
|
||||||
const filter = computeDataTableFilter('', false, [
|
const filter = computeDataTableFilter('', false, [
|
||||||
|
...baseColumns,
|
||||||
|
{
|
||||||
|
key: 'extras.comment',
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
value: 'dull',
|
||||||
|
label: 'dull',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])!;
|
||||||
|
expect(data.filter(filter)).toEqual([espresso]);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
//filter 'level' on values info and error which will match all records
|
||||||
|
const filter = computeDataTableFilter('', false, [
|
||||||
|
doneCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -477,6 +530,7 @@ test('compute filters', () => {
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('', false, [
|
const filter = computeDataTableFilter('', false, [
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -503,6 +557,8 @@ test('compute filters', () => {
|
|||||||
{
|
{
|
||||||
// inverse filter
|
// inverse filter
|
||||||
const filter = computeDataTableFilter('', false, [
|
const filter = computeDataTableFilter('', false, [
|
||||||
|
doneCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -520,6 +576,8 @@ test('compute filters', () => {
|
|||||||
{
|
{
|
||||||
// inverse filter with search
|
// inverse filter with search
|
||||||
const filter = computeDataTableFilter('coffee', false, [
|
const filter = computeDataTableFilter('coffee', false, [
|
||||||
|
doneCol,
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
@@ -536,6 +594,7 @@ test('compute filters', () => {
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
const filter = computeDataTableFilter('nonsense', false, [
|
const filter = computeDataTableFilter('nonsense', false, [
|
||||||
|
titleCol,
|
||||||
{
|
{
|
||||||
key: 'level',
|
key: 'level',
|
||||||
filters: [
|
filters: [
|
||||||
|
|||||||
Reference in New Issue
Block a user