Use DataList

Summary:
With new abstraction, `DataList` matches what the plugin trying to render.

Should fix:
https://fb.workplace.com/groups/flippersupport/permalink/1145431339270856/

Changelog: [MobileConfig] Fix issues with scrolling not working and several other improvements

Reviewed By: cekkaewnumchai

Differential Revision: D28314408

fbshipit-source-id: 4d8fbe3d8e868f737750203cd568d94bae8b4108
This commit is contained in:
Michel Weststrate
2021-06-16 07:14:02 -07:00
committed by Facebook GitHub Bot
parent 34c862d5f2
commit 0aadb862ee
7 changed files with 231 additions and 144 deletions

View File

@@ -7,14 +7,7 @@
* @format * @format
*/ */
import React, { import React, {memo, createRef, useMemo, useEffect, useCallback} from 'react';
useCallback,
memo,
createRef,
useEffect,
useMemo,
useState,
} from 'react';
import {DataFormatter} from './DataFormatter'; import {DataFormatter} from './DataFormatter';
import {Layout} from './Layout'; import {Layout} from './Layout';
import {Typography} from 'antd'; import {Typography} from 'antd';
@@ -28,19 +21,13 @@ import {RightOutlined} from '@ant-design/icons';
import {theme} from './theme'; import {theme} from './theme';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {DataTableManager} from './data-table/DataTableManager'; import {DataTableManager} from './data-table/DataTableManager';
import {Atom, createState} from '../state/atom';
import {useAssertStableRef} from '../utils/useAssertStableRef'; import {useAssertStableRef} from '../utils/useAssertStableRef';
import {DataSource} from '../data-source'; import {DataSource} from '../data-source';
import {useMakeStableCallback} from '../utils/useMakeStableCallback';
const {Text} = Typography; const {Text} = Typography;
interface Item { type DataListBaseProps<Item> = {
id: string;
title: string;
description?: string;
}
interface DataListBaseProps<T extends Item> {
/** /**
* Defines the styling of the component. By default shows a list, but alternatively the items can be displayed in a drop down * Defines the styling of the component. By default shows a list, but alternatively the items can be displayed in a drop down
*/ */
@@ -50,14 +37,11 @@ interface DataListBaseProps<T extends Item> {
* By setting `scrollable={false}` the list will only take its natural size * By setting `scrollable={false}` the list will only take its natural size
*/ */
scrollable?: boolean; scrollable?: boolean;
/**
* The current selection
*/
selection: Atom<string | undefined>;
/** /**
* Handler that is fired if selection is changed * Handler that is fired if selection is changed
*/ */
onSelect?(id: string | undefined, value: T | undefined): void; selection?: string | undefined;
onSelect?(id: string | undefined, value: Item | undefined): void;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
/** /**
@@ -67,85 +51,102 @@ interface DataListBaseProps<T extends Item> {
/** /**
* Custom render function. By default the component will render the `title` in bold and description (if any) below it * Custom render function. By default the component will render the `title` in bold and description (if any) below it
*/ */
onRenderItem?: ItemRenderer<T>; onRenderItem?: ItemRenderer<Item>;
/**
* The attributes that will be picked as id/title/description for the default rendering.
* Defaults to id/title/description, but can be customized
*/
titleAttribute?: keyof Item & string;
descriptionAttribute?: keyof Item & string;
/** /**
* Show a right arrow by default * Show a right arrow by default
*/ */
enableArrow?: boolean; enableArrow?: boolean;
} } & (Item extends {id: string}
? {
idAttribute?: keyof Item & string; // optional if id field is present
}
: {
idAttribute: keyof Item & string;
});
export type DataListProps<T extends Item> = DataListBaseProps<T> & export type DataListProps<Item> = DataListBaseProps<Item> &
// Some table props are set by DataList instead, so override them // Some table props are set by DataList instead, so override them
Omit<DataTableProps<T>, 'records' | 'dataSource' | 'columns' | 'onSelect'>; Omit<DataTableProps<Item>, 'records' | 'dataSource' | 'columns' | 'onSelect'>;
export const DataList: React.FC<DataListProps<any>> = function DataList< export const DataList: (<T>(props: DataListProps<T>) => React.ReactElement) & {
T extends Item, Item: React.FC<DataListItemProps>;
>({ } = Object.assign(
selection: baseSelection, function <T>({
onSelect, onSelect: baseOnSelect,
selection,
className, className,
style, style,
items, items,
onRenderItem, onRenderItem,
enableArrow, enableArrow,
idAttribute,
titleAttribute,
descriptionAttribute,
...tableProps ...tableProps
}: DataListProps<T>) { }: DataListProps<T>) {
// if a tableManagerRef is provided, we piggy back on that same ref // if a tableManagerRef is provided, we piggy back on that same ref
// eslint-disable-next-line // eslint-disable-next-line
const tableManagerRef = tableProps.tableManagerRef ?? createRef<undefined | DataTableManager<T>>(); const tableManagerRef = tableProps.tableManagerRef ?? createRef<undefined | DataTableManager<T>>();
useAssertStableRef(baseSelection, 'selection');
useAssertStableRef(onRenderItem, 'onRenderItem'); useAssertStableRef(onRenderItem, 'onRenderItem');
useAssertStableRef(enableArrow, 'enableArrow'); useAssertStableRef(enableArrow, 'enableArrow');
const onSelect = useMakeStableCallback(baseOnSelect);
// create local selection atom if none provided
// eslint-disable-next-line
const selection = baseSelection ?? useState(() => createState<string|undefined>())[0];
const handleSelect = useCallback( const handleSelect = useCallback(
(item: T | undefined) => { (item: T | undefined) => {
selection.set(item?.id); if (!item) {
onSelect?.(undefined, undefined);
} else {
const id = '' + item[idAttribute!];
if (id == null) {
throw new Error(`No valid identifier for attribute ${idAttribute}`);
}
onSelect?.(id, item);
}
}, },
[selection], [onSelect, idAttribute],
); );
useEffect(() => {
if (selection) {
tableManagerRef.current?.selectItemById(selection);
} else {
tableManagerRef.current?.clearSelection();
}
}, [selection, tableManagerRef]);
const dataListColumns: DataTableColumn<T>[] = useMemo( const dataListColumns: DataTableColumn<T>[] = useMemo(
() => [ () => [
{ {
key: 'id' as const, key: idAttribute!,
wrap: true, wrap: true,
onRender(item: T, selected: boolean, index: number) { onRender(item: T, selected: boolean, index: number) {
return onRenderItem ? ( return onRenderItem ? (
onRenderItem(item, selected, index) onRenderItem(item, selected, index)
) : ( ) : (
<DataListItem <DataList.Item
title={item.title} title={item[titleAttribute!] as any}
description={item.description} description={item[descriptionAttribute!] as any}
enableArrow={enableArrow} enableArrow={enableArrow}
/> />
); );
}, },
}, },
], ],
[onRenderItem, enableArrow], [
); onRenderItem,
enableArrow,
useEffect( titleAttribute,
function updateSelection() { descriptionAttribute,
return selection.subscribe((valueFromAtom) => { idAttribute,
const m = tableManagerRef.current; ],
if (!m) {
return;
}
if (!valueFromAtom && m.getSelectedItem()) {
m.clearSelection();
} else if (valueFromAtom && m.getSelectedItem()?.id !== valueFromAtom) {
// find valueFromAtom in the selection
m.selectItemById(valueFromAtom);
}
});
},
[selection, tableManagerRef],
); );
return ( return (
@@ -153,47 +154,33 @@ export const DataList: React.FC<DataListProps<any>> = function DataList<
<DataTable<any> <DataTable<any>
{...tableProps} {...tableProps}
tableManagerRef={tableManagerRef} tableManagerRef={tableManagerRef}
records={Array.isArray(items) ? items : undefined} records={Array.isArray(items) ? items : undefined!}
dataSource={Array.isArray(items) ? undefined : (items as any)} dataSource={Array.isArray(items) ? undefined! : (items as any)}
recordsKey="id" recordsKey={idAttribute}
columns={dataListColumns} columns={dataListColumns}
onSelect={handleSelect} onSelect={handleSelect}
/> />
</Layout.Container> </Layout.Container>
); );
}; },
{
DataList.defaultProps = { Item: memo(({title, description, enableArrow}: DataListItemProps) => {
type: 'default',
scrollable: true,
enableSearchbar: false,
enableColumnHeaders: false,
enableArrow: true,
enableContextMenu: false,
enableMultiSelect: false,
};
const DataListItem = memo(
({
title,
description,
enableArrow,
}: {
// TODO: add icon support
title: string;
description?: string;
enableArrow?: boolean;
}) => {
return ( return (
<Layout.Horizontal center grow shrink padv> <Layout.Horizontal center grow shrink padv>
<Layout.Container grow shrink> <Layout.Container grow shrink>
{typeof title === 'string' ? (
<Text strong ellipsis> <Text strong ellipsis>
{DataFormatter.format(title)} {DataFormatter.format(title)}
</Text> </Text>
{description != null && ( ) : (
title
)}
{typeof description === 'string' ? (
<Text type="secondary" ellipsis> <Text type="secondary" ellipsis>
{DataFormatter.format(description)} {DataFormatter.format(description)}
</Text> </Text>
) : (
description
)} )}
</Layout.Container> </Layout.Container>
{enableArrow && ( {enableArrow && (
@@ -203,9 +190,33 @@ const DataListItem = memo(
)} )}
</Layout.Horizontal> </Layout.Horizontal>
); );
}),
}, },
); );
(DataList as React.FC<DataListProps<any>>).defaultProps = {
type: 'default',
scrollable: true,
enableSearchbar: false,
enableColumnHeaders: false,
enableArrow: true,
enableContextMenu: false,
enableMultiSelect: false,
idAttribute: 'id',
titleAttribute: 'title',
descriptionAttribute: 'description',
};
(DataList.Item as React.FC<DataListItemProps>).defaultProps = {
enableArrow: true,
};
interface DataListItemProps {
// TODO: add icon support
title?: string | React.ReactElement;
description?: string | React.ReactElement;
enableArrow?: boolean;
}
const ArrowWrapper = styled.div({ const ArrowWrapper = styled.div({
flex: 0, flex: 0,
paddingLeft: theme.space.small, paddingLeft: theme.space.small,

View File

@@ -60,6 +60,7 @@ interface DataTableBaseProps<T = any> {
enableColumnHeaders?: boolean; enableColumnHeaders?: boolean;
enableMultiSelect?: boolean; enableMultiSelect?: boolean;
enableContextMenu?: boolean; enableContextMenu?: boolean;
enablePersistSettings?: boolean;
// if set (the default) will grow and become scrollable. Otherwise will use natural size // if set (the default) will grow and become scrollable. Otherwise will use natural size
scrollable?: boolean; scrollable?: boolean;
extraActions?: React.ReactElement; extraActions?: React.ReactElement;
@@ -153,6 +154,7 @@ export function DataTable<T extends object>(
scope, scope,
virtualizerRef, virtualizerRef,
autoScroll: props.enableAutoScroll, autoScroll: props.enableAutoScroll,
enablePersistSettings: props.enablePersistSettings,
}), }),
); );
@@ -177,15 +179,20 @@ export function DataTable<T extends object>(
const renderingConfig = useMemo<TableRowRenderContext<T>>(() => { const renderingConfig = useMemo<TableRowRenderContext<T>>(() => {
let startIndex = 0; let startIndex = 0;
return { return {
columns: visibleColumns, columns: visibleColumns,
onMouseEnter(e, _item, index) { onMouseEnter(e, _item, index) {
if (dragging.current && e.buttons === 1) { if (dragging.current && e.buttons === 1 && props.enableMultiSelect) {
// by computing range we make sure no intermediate items are missed when scrolling fast // by computing range we make sure no intermediate items are missed when scrolling fast
tableManager.addRangeToSelection(startIndex, index); tableManager.addRangeToSelection(startIndex, index);
} }
}, },
onMouseDown(e, _item, index) { onMouseDown(e, _item, index) {
if (!props.enableMultiSelect && e.buttons > 1) {
tableManager.selectItem(index, false, true);
return;
}
if (!dragging.current) { if (!dragging.current) {
if (e.buttons > 1) { if (e.buttons > 1) {
// for right click we only want to add if needed, not deselect // for right click we only want to add if needed, not deselect
@@ -218,7 +225,13 @@ export function DataTable<T extends object>(
} }
: undefined, : undefined,
}; };
}, [visibleColumns, tableManager, onRowStyle, props.enableContextMenu]); }, [
visibleColumns,
tableManager,
onRowStyle,
props.enableContextMenu,
props.enableMultiSelect,
]);
const itemRenderer = useCallback( const itemRenderer = useCallback(
function itemRenderer( function itemRenderer(
@@ -454,7 +467,7 @@ export function DataTable<T extends object>(
searchValue={searchValue} searchValue={searchValue}
useRegex={tableState.useRegex} useRegex={tableState.useRegex}
dispatch={dispatch as any} dispatch={dispatch as any}
contextMenu={contexMenu} contextMenu={props.enableContextMenu ? contexMenu : undefined}
extraActions={props.extraActions} extraActions={props.extraActions}
/> />
)} )}

View File

@@ -98,6 +98,7 @@ type DataManagerConfig<T> = {
onSelect: undefined | ((item: T | undefined, items: T[]) => void); onSelect: undefined | ((item: T | undefined, items: T[]) => void);
virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>; virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>;
autoScroll?: boolean; autoScroll?: boolean;
enablePersistSettings?: boolean;
}; };
type DataManagerState<T> = { type DataManagerState<T> = {
@@ -272,6 +273,7 @@ export type DataTableManager<T> = {
toggleColumnVisibility(column: keyof T): void; toggleColumnVisibility(column: keyof T): void;
sortColumn(column: keyof T, direction?: SortDirection): void; sortColumn(column: keyof T, direction?: SortDirection): void;
setSearchValue(value: string): void; setSearchValue(value: string): void;
dataSource: DataSource<T>;
}; };
export function createDataTableManager<T>( export function createDataTableManager<T>(
@@ -315,6 +317,7 @@ export function createDataTableManager<T>(
setSearchValue(value) { setSearchValue(value) {
dispatch({type: 'setSearchValue', value}); dispatch({type: 'setSearchValue', value});
}, },
dataSource,
}; };
} }
@@ -324,7 +327,9 @@ export function createInitialState<T>(
const storageKey = `${config.scope}:DataTable:${config.defaultColumns const storageKey = `${config.scope}:DataTable:${config.defaultColumns
.map((c) => c.key) .map((c) => c.key)
.join(',')}`; .join(',')}`;
const prefs = loadStateFromStorage(storageKey); const prefs = config.enablePersistSettings
? loadStateFromStorage(storageKey)
: undefined;
let initialColumns = computeInitialColumns(config.defaultColumns); let initialColumns = computeInitialColumns(config.defaultColumns);
if (prefs) { if (prefs) {
// merge prefs with the default column config // merge prefs with the default column config
@@ -411,7 +416,7 @@ export function savePreferences(
state: DataManagerState<any>, state: DataManagerState<any>,
scrollOffset: number, scrollOffset: number,
) { ) {
if (!state.config.scope) { if (!state.config.scope || !state.config.enablePersistSettings) {
return; return;
} }
const prefs: PersistedState = { const prefs: PersistedState = {

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useEffect, useRef} from 'react';
/**
* Creates a ref object that is always synced from the value passed in.
*/
export function useLatestRef<T>(latest: T): {readonly current: T} {
const latestRef = useRef(latest);
// TODO: sync eagerly (in render) or late? Introduce a `syncEarly` flag as second arg
useEffect(() => {
latestRef.current = latest;
}, [latest]);
return latestRef;
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useCallback} from 'react';
import {useLatestRef} from './useLatestRef';
/**
* This hook can be used to avoid forcing consumers of a component to wrap their callbacks
* in useCallback, by creating wrapper callback that redirects to the lastest prop passed in.
*
* Use this hook if you would like to avoid that passing a new callback to this component,
* will cause child components to rerender when the callback is passed further down.
*
* Use it like: `const onSelect = useMakeStableCallback(props.onSelect)`.
* @param fn
*/
export function useMakeStableCallback<
T extends undefined | ((...args: any[]) => any),
>(fn: T): T {
const latestFn = useLatestRef(fn);
return useCallback(
(...args: any[]) => {
return latestFn.current?.apply(null, args);
},
[latestFn],
) as any;
}

View File

@@ -37,7 +37,7 @@ export function Crashes() {
title: crash.reason ?? crash.name, title: crash.reason ?? crash.name,
description: `${crash.date.toLocaleString()} - ${crash.name}`, description: `${crash.date.toLocaleString()} - ${crash.name}`,
}))} }))}
selection={plugin.selectedCrash} selection={selectedCrashId}
onRenderEmpty={null} onRenderEmpty={null}
/> />
{selectedCrash ? ( {selectedCrash ? (

View File

@@ -159,7 +159,7 @@ export function ManageMockResponsePanel(props: Props) {
</Toolbar> </Toolbar>
<DataList <DataList
items={items} items={items}
selection={selectedIdAtom} selection={selectedId}
onRenderItem={handleRender} onRenderItem={handleRender}
scrollable scrollable
/> />