Implement deeplink, creating pastes, log deduplication

Summary:
This diff implements the remaining features in the logs plugin:

- deeplinking
- merging duplicate rows

The logs plugin source code has now been reduced from originally `935` to `285` LoC. All optimisation code has been removed from the plugin:

* debouncing data processing
* pre-rendering (and storing!) all rows

Finally applied some further styling tweaks and applied some renames to DataTable / DataSource + types finetuning. Some more will follow.
Fixed a emotion warning in unit tests which was pretty annoying.

Reviewed By: passy

Differential Revision: D26666190

fbshipit-source-id: e45e289b4422ebeb46cad927cfc0cfcc9566834f
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent dec8e88aeb
commit 525e079284
17 changed files with 132 additions and 70 deletions

View File

@@ -228,8 +228,6 @@ const persistor = persistStore(store, undefined, () => {
setPersistor(persistor);
const CodeBlock = styled(Input.TextArea)({
fontFamily:
'SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;',
fontSize: '0.8em',
...theme.monospace,
color: theme.textColorSecondary,
});

View File

@@ -42,7 +42,7 @@ export function SidebarTitle({
const LeftMenuTitle = styled(Layout.Horizontal)({
padding: `0px ${theme.inlinePaddingH}px`,
lineHeight: `${theme.space.large}px`,
fontSize: theme.fontSize.smallBody,
fontSize: theme.fontSize.small,
textTransform: 'uppercase',
'> :first-child': {
flex: 1,

View File

@@ -83,7 +83,7 @@ function ResultTopDialog(props: {status: HealthcheckStatus}) {
showIcon
message={messages.message}
style={{
fontSize: theme.fontSize.smallBody,
fontSize: theme.fontSize.small,
lineHeight: '16px',
fontWeight: 'bold',
paddingTop: '10px',
@@ -179,7 +179,7 @@ function SetupDoctorFooter(props: {
checked={props.acknowledgeCheck}
onChange={(e) => props.onAcknowledgeCheck(e.target.checked)}
style={{display: 'flex', alignItems: 'center'}}>
<Text style={{fontSize: theme.fontSize.smallBody}}>
<Text style={{fontSize: theme.fontSize.small}}>
Do not show warning about these problems at startup
</Text>
</Checkbox>

View File

@@ -78,7 +78,7 @@ function WelcomeFooter({
return (
<FooterContainer>
<Checkbox checked={checked} onChange={(e) => onCheck(e.target.checked)}>
<Text style={{fontSize: theme.fontSize.smallBody}}>
<Text style={{fontSize: theme.fontSize.small}}>
Show this when app opens (or use ? icon on left)
</Text>
</Checkbox>

View File

@@ -45,7 +45,7 @@ export default function BlocklistSettingButton(props: {
key={pluginId}
closable
onClose={() => props.onRemovePlugin(pluginId)}>
<Text style={{fontSize: theme.fontSize.smallBody}} ellipsis>
<Text style={{fontSize: theme.fontSize.small}} ellipsis>
{pluginId}
</Text>
</Tag>
@@ -54,7 +54,7 @@ export default function BlocklistSettingButton(props: {
) : (
<Text
style={{
fontSize: theme.fontSize.smallBody,
fontSize: theme.fontSize.small,
color: theme.textColorSecondary,
}}>
No Blocklisted Plugin
@@ -70,7 +70,7 @@ export default function BlocklistSettingButton(props: {
key={category}
closable
onClose={() => props.onRemoveCategory(category)}>
<Text style={{fontSize: theme.fontSize.smallBody}} ellipsis>
<Text style={{fontSize: theme.fontSize.small}} ellipsis>
{category}
</Text>
</Tag>
@@ -79,7 +79,7 @@ export default function BlocklistSettingButton(props: {
) : (
<Text
style={{
fontSize: theme.fontSize.smallBody,
fontSize: theme.fontSize.small,
color: theme.textColorSecondary,
}}>
No Blocklisted Category

View File

@@ -69,7 +69,7 @@ function DetailCollapse({detail}: {detail: string | React.ReactNode}) {
<Paragraph
type="secondary"
style={{
fontSize: theme.fontSize.smallBody,
fontSize: theme.fontSize.small,
marginBottom: 0,
}}
ellipsis={{rows: 3}}>
@@ -92,7 +92,7 @@ function DetailCollapse({detail}: {detail: string | React.ReactNode}) {
<Collapse.Panel
key="detail"
header={
<Text type="secondary" style={{fontSize: theme.fontSize.smallBody}}>
<Text type="secondary" style={{fontSize: theme.fontSize.small}}>
View detail
</Text>
}>
@@ -149,14 +149,14 @@ function NotificationEntry({notification}: {notification: PluginNotification}) {
<Layout.Right center>
<Layout.Horizontal gap="tiny" center>
{icon}
<Text style={{fontSize: theme.fontSize.smallBody}}>{pluginName}</Text>
<Text style={{fontSize: theme.fontSize.small}}>{pluginName}</Text>
</Layout.Horizontal>
{actions}
</Layout.Right>
<Title level={4} ellipsis={{rows: 2}}>
{title}
</Title>
<Text type="secondary" style={{fontSize: theme.fontSize.smallBody}}>
<Text type="secondary" style={{fontSize: theme.fontSize.small}}>
{clientName && appName
? `${clientName}/${appName}`
: clientName ?? appName ?? 'Not Connected'}

View File

@@ -56,6 +56,7 @@ test('Correct top level API exposed', () => {
Array [
"Atom",
"DataTableColumn",
"DataTableManager",
"DefaultKeyboardAction",
"Device",
"DeviceLogEntry",

View File

@@ -78,6 +78,7 @@ export {Idler} from './utils/Idler';
export {createDataSource, DataSource} from './state/datasource/DataSource';
export {DataTable, DataTableColumn} from './ui/datatable/DataTable';
export {DataTableManager} from './ui/datatable/useDataTableManager';
export {
Interactive as _Interactive,

View File

@@ -108,11 +108,8 @@ export class DataSource<
output: Entry<T>[] = [];
/**
* Returns a direct reference to the stored records.
* The collection should be treated as readonly and mutable;
* the collection might be directly written to by the datasource,
* so for an immutable state create a defensive copy:
* `datasource.records.slice()`
* Returns a defensive copy of the stored records.
* This is a O(n) operation! Prefer using .size and .get instead!
*/
get records(): readonly T[] {
return this._records.map(unwrap);
@@ -134,6 +131,18 @@ export class DataSource<
this.setSortBy(undefined);
}
public get size() {
return this._records.length;
}
public getRecord(index: number): T {
return this._records[index]?.value;
}
public get outputSize() {
return this.output.length;
}
/**
* Returns a defensive copy of the current output.
* Sort, filter, reverse and window are applied, but windowing isn't.

View File

@@ -203,11 +203,11 @@ const SandySplitContainer = styled.div<{
alignItems: props.center ? 'center' : 'stretch',
gap: normalizeSpace(props.gap, theme.space.small),
overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues
'> :nth-child(1)': {
'>:nth-of-type(1)': {
flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,
minWidth: props.grow === 1 ? 0 : undefined,
},
'> :nth-child(2)': {
'>:nth-of-type(2)': {
flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle,
minWidth: props.grow === 2 ? 0 : undefined,
},

View File

@@ -16,6 +16,7 @@ import React, {
RefObject,
MutableRefObject,
CSSProperties,
useEffect,
} from 'react';
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
import {DataSource} from '../../state/datasource/DataSource';
@@ -23,7 +24,7 @@ import {Layout} from '../Layout';
import {TableHead} from './TableHead';
import {Percentage} from '../../utils/widthUtils';
import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
import {useDataTableManager, TableManager} from './useDataTableManager';
import {useDataTableManager, DataTableManager} from './useDataTableManager';
import {TableSearch} from './TableSearch';
import styled from '@emotion/styled';
import {theme} from '../theme';
@@ -40,7 +41,7 @@ interface DataTableProps<T = any> {
onSelect?(record: T | undefined, records: T[]): void;
onRowStyle?(record: T): CSSProperties | undefined;
// multiselect?: true
tableManagerRef?: RefObject<TableManager>;
tableManagerRef?: RefObject<DataTableManager<T> | undefined>; // Actually we want a MutableRefObject, but that is not what React.createRef() returns, and we don't want to put the burden on the plugin dev to cast it...
_testHeight?: number; // exposed for unit testing only
}
@@ -92,7 +93,9 @@ export function DataTable<T extends object>(
props.onSelect,
);
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
(props.tableManagerRef as MutableRefObject<
DataTableManager<T>
>).current = tableManager;
}
const {
visibleColumns,
@@ -246,6 +249,17 @@ export function DataTable<T extends object>(
return <EmptyTable dataSource={dataSource} />;
}, []);
useEffect(
function cleanup() {
return () => {
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<undefined>).current = undefined;
}
};
},
[props.tableManagerRef],
);
return (
<Layout.Container grow>
<Layout.Top>

View File

@@ -9,7 +9,7 @@
import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
import {Checkbox, Menu} from 'antd';
import {TableManager} from './useDataTableManager';
import {DataTableManager} from './useDataTableManager';
import React from 'react';
import {normalizeCellValue} from './TableRow';
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
@@ -17,7 +17,7 @@ import {DataTableColumn} from './DataTable';
const {Item, SubMenu} = Menu;
export function tableContextMenuFactory(tableManager: TableManager) {
export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
const lib = tryGetFlipperLibImplementation();
if (!lib) {
return (

View File

@@ -25,7 +25,6 @@ import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
import {Layout} from '../Layout';
import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager';
import {ColumnFilterHandlers, FilterIcon} from './ColumnFilter';
import {DEFAULT_ROW_HEIGHT} from './TableRow';
const {Text} = Typography;
@@ -41,27 +40,27 @@ function SortIcons({
<CaretUpFilled
onClick={(e) => {
e.stopPropagation();
onSort(direction === 'up' ? undefined : 'up');
onSort(direction === 'asc' ? undefined : 'asc');
}}
className={
'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '')
'ant-table-column-sorter-up ' + (direction === 'asc' ? 'active' : '')
}
/>
<CaretDownFilled
onClick={(e) => {
e.stopPropagation();
onSort(direction === 'down' ? undefined : 'down');
onSort(direction === 'desc' ? undefined : 'desc');
}}
className={
'ant-table-column-sorter-down ' +
(direction === 'down' ? 'active' : '')
(direction === 'desc' ? 'active' : '')
}
/>
</SortIconsContainer>
);
}
const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
const SortIconsContainer = styled.span<{direction?: 'asc' | 'desc'}>(
({direction}) => ({
visibility: direction === undefined ? 'hidden' : undefined,
display: 'inline-flex',
@@ -127,7 +126,7 @@ function TableHeadColumn({
...filterHandlers
}: {
column: DataTableColumn<any>;
sorted: 'up' | 'down' | undefined;
sorted: SortDirection;
isResizable: boolean;
onSort: (id: string, direction: SortDirection) => void;
sortOrder: undefined | Sorting;
@@ -169,7 +168,7 @@ function TableHeadColumn({
e.stopPropagation();
onSort(
column.key,
sorted === 'up' ? undefined : sorted === 'down' ? 'up' : 'down',
sorted === 'asc' ? 'desc' : sorted === 'desc' ? undefined : 'asc',
);
}}
role="button"

View File

@@ -49,7 +49,6 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
? `4px solid ${theme.primaryColor}`
: `4px solid transparent`,
paddingTop: 1,
borderBottom: `1px solid ${theme.dividerColor}`,
minHeight: DEFAULT_ROW_HEIGHT,
lineHeight: `${DEFAULT_ROW_HEIGHT - 2}px`,
'& .anticon': {
@@ -75,6 +74,7 @@ const TableBodyColumnContainer = styled.div<{
flexGrow: props.width === undefined ? 1 : 0,
overflow: 'hidden',
padding: `0 ${theme.space.small}px`,
borderBottom: `1px solid ${theme.dividerColor}`,
verticalAlign: 'top',
whiteSpace: props.multiline ? 'normal' : 'nowrap',
wordWrap: props.multiline ? 'break-word' : 'normal',

View File

@@ -11,7 +11,7 @@ import React, {createRef} from 'react';
import {DataTable, DataTableColumn} from '../DataTable';
import {render, act} from '@testing-library/react';
import {createDataSource} from '../../../state/datasource/DataSource';
import {computeDataTableFilter, TableManager} from '../useDataTableManager';
import {computeDataTableFilter, DataTableManager} from '../useDataTableManager';
import {Button} from 'antd';
type Todo = {
@@ -41,7 +41,7 @@ const columns: DataTableColumn[] = [
test('update and append', async () => {
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const ref = createRef<DataTableManager<Todo>>();
const rendering = render(
<DataTable
dataSource={ds}
@@ -55,15 +55,15 @@ test('update and append', async () => {
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="css-8pa5c2-TableBodyRowContainer efe0za01"
class="css-1b7miqb-TableBodyRowContainer efe0za01"
>
<div
class="css-kkcfb6-TableBodyColumnContainer efe0za00"
class="css-bqa56k-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="css-kkcfb6-TableBodyColumnContainer efe0za00"
class="css-bqa56k-TableBodyColumnContainer efe0za00"
>
true
</div>
@@ -98,7 +98,7 @@ test('update and append', async () => {
test('column visibility', async () => {
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const ref = createRef<DataTableManager<Todo>>();
const rendering = render(
<DataTable
dataSource={ds}
@@ -112,15 +112,15 @@ test('column visibility', async () => {
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="css-8pa5c2-TableBodyRowContainer efe0za01"
class="css-1b7miqb-TableBodyRowContainer efe0za01"
>
<div
class="css-kkcfb6-TableBodyColumnContainer efe0za00"
class="css-bqa56k-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="css-kkcfb6-TableBodyColumnContainer efe0za00"
class="css-bqa56k-TableBodyColumnContainer efe0za00"
>
true
</div>
@@ -137,10 +137,10 @@ test('column visibility', async () => {
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="css-8pa5c2-TableBodyRowContainer efe0za01"
class="css-1b7miqb-TableBodyRowContainer efe0za01"
>
<div
class="css-kkcfb6-TableBodyColumnContainer efe0za00"
class="css-bqa56k-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
@@ -174,7 +174,7 @@ test('sorting', async () => {
title: 'item b',
done: false,
});
const ref = createRef<TableManager>();
const ref = createRef<DataTableManager<Todo>>();
const rendering = render(
<DataTable
dataSource={ds}
@@ -195,7 +195,7 @@ test('sorting', async () => {
}
// sort asc
act(() => {
ref.current?.sortColumn('title', 'down');
ref.current?.sortColumn('title', 'asc');
});
{
const elem = await rendering.findAllByText(/item/);
@@ -208,7 +208,7 @@ test('sorting', async () => {
}
// sort desc
act(() => {
ref.current?.sortColumn('title', 'up');
ref.current?.sortColumn('title', 'desc');
});
{
const elem = await rendering.findAllByText(/item/);
@@ -249,7 +249,7 @@ test('search', async () => {
title: 'item b',
done: false,
});
const ref = createRef<TableManager>();
const ref = createRef<DataTableManager<Todo>>();
const rendering = render(
<DataTable
dataSource={ds}
@@ -514,7 +514,7 @@ test('compute filters', () => {
test('onSelect callback fires, and in order', () => {
const events: any[] = [];
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const ref = createRef<DataTableManager<Todo>>();
const rendering = render(
<DataTable
dataSource={ds}
@@ -566,7 +566,7 @@ test('onSelect callback fires, and in order', () => {
test('selection always has the latest state', () => {
const events: any[] = [];
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const ref = createRef<DataTableManager<Todo>>();
const rendering = render(
<DataTable
dataSource={ds}

View File

@@ -10,7 +10,7 @@
import {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
import {Percentage} from '../../utils/widthUtils';
import produce from 'immer';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {DataSource} from '../../state/datasource/DataSource';
import {useMemoize} from '../../utils/useMemoize';
@@ -20,9 +20,45 @@ export type Sorting = {
direction: Exclude<SortDirection, undefined>;
};
export type SortDirection = 'up' | 'down' | undefined;
export type SortDirection = 'asc' | 'desc' | undefined;
export type TableManager = ReturnType<typeof useDataTableManager>;
export interface DataTableManager<T> {
/** The default columns, but normalized */
columns: DataTableColumn<T>[];
/** The effective columns to be rendererd */
visibleColumns: DataTableColumn<T>[];
/** The currently applicable sorting, if any */
sorting: Sorting | undefined;
/** Reset the current table preferences, including column widths an visibility, back to the default */
reset(): void;
/** Resizes the column with the given key to the given width */
resizeColumn(column: string, width: number | Percentage): void;
/** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
sortColumn(column: string, direction: SortDirection): void;
/** Show / hide the given column */
toggleColumnVisibility(column: string): void;
/** Active search value */
setSearchValue(value: string): void;
/** current selection, describes the index index in the datasources's current output (not window) */
selection: Selection;
selectItem(
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
): void;
addRangeToSelection(
start: number,
end: number,
allowUnselect?: boolean,
): void;
clearSelection(): void;
getSelectedItem(): T | undefined;
getSelectedItems(): readonly T[];
/** Changing column filters */
addColumnFilter(column: string, value: string, disableOthers?: boolean): void;
removeColumnFilter(column: string, index: number): void;
toggleColumnFilter(column: string, index: number): void;
setColumnFilterFromSelection(column: string): void;
}
type Selection = {items: ReadonlySet<number>; current: number};
@@ -38,15 +74,13 @@ export function useDataTableManager<T>(
dataSource: DataSource<T>,
defaultColumns: DataTableColumn<T>[],
onSelect?: (item: T | undefined, items: T[]) => void,
) {
): DataTableManager<T> {
const [columns, setEffectiveColumns] = useState(
computeInitialColumns(defaultColumns),
);
// TODO: move selection with shifts with index < selection?
// TODO: clear selection if out of range
const [selection, setSelection] = useState<Selection>(emptySelection);
const selectionRef = useRef(selection);
selectionRef.current = selection; // store last seen selection for fetching it later
const [sorting, setSorting] = useState<Sorting | undefined>(undefined);
const [searchValue, setSearchValue] = useState('');
@@ -86,21 +120,22 @@ export function useDataTableManager<T>(
[],
);
// 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 clearSelection = useCallback(() => {
setSelection(emptySelection);
}, []);
const getSelectedItem = useCallback(() => {
return selectionRef.current.current < 0
return selection.current < 0
? undefined
: dataSource.getItem(selectionRef.current.current);
}, [dataSource]);
: dataSource.getItem(selection.current);
}, [dataSource, selection]);
const getSelectedItems = useCallback(() => {
return [...selectionRef.current.items]
return [...selection.items]
.sort()
.map((i) => dataSource.getItem(i))
.filter(Boolean) as any[];
}, [dataSource]);
}, [dataSource, selection]);
useEffect(
function fireSelection() {
@@ -221,7 +256,7 @@ export function useDataTableManager<T>(
dataSource.setSortBy(key as any);
}
if (!sorting || sorting.direction !== direction) {
dataSource.setReversed(direction === 'up');
dataSource.setReversed(direction === 'desc');
}
setSorting({key, direction});
}
@@ -271,6 +306,7 @@ export function useDataTableManager<T>(
selection,
selectItem,
addRangeToSelection,
clearSelection,
getSelectedItem,
getSelectedItems,
/** Changing column filters */

View File

@@ -8,7 +8,6 @@
*/
// Exposes all the variables defined in themes/base.less:
export const theme = {
white: 'white', // use as counter color for primary
black: 'black',
@@ -38,7 +37,12 @@ export const theme = {
huge: 24,
} as const,
fontSize: {
smallBody: '12px',
default: '14px',
small: '12px',
} as const,
monospace: {
fontFamily: 'SF Mono,Monaco,Andale Mono,monospace',
fontSize: '12px',
} as const,
} as const;