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); setPersistor(persistor);
const CodeBlock = styled(Input.TextArea)({ const CodeBlock = styled(Input.TextArea)({
fontFamily: ...theme.monospace,
'SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;',
fontSize: '0.8em',
color: theme.textColorSecondary, color: theme.textColorSecondary,
}); });

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ function WelcomeFooter({
return ( return (
<FooterContainer> <FooterContainer>
<Checkbox checked={checked} onChange={(e) => onCheck(e.target.checked)}> <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) Show this when app opens (or use ? icon on left)
</Text> </Text>
</Checkbox> </Checkbox>

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,11 +108,8 @@ export class DataSource<
output: Entry<T>[] = []; output: Entry<T>[] = [];
/** /**
* Returns a direct reference to the stored records. * Returns a defensive copy of the stored records.
* The collection should be treated as readonly and mutable; * This is a O(n) operation! Prefer using .size and .get instead!
* the collection might be directly written to by the datasource,
* so for an immutable state create a defensive copy:
* `datasource.records.slice()`
*/ */
get records(): readonly T[] { get records(): readonly T[] {
return this._records.map(unwrap); return this._records.map(unwrap);
@@ -134,6 +131,18 @@ export class DataSource<
this.setSortBy(undefined); 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. * Returns a defensive copy of the current output.
* Sort, filter, reverse and window are applied, but windowing isn't. * 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', alignItems: props.center ? 'center' : 'stretch',
gap: normalizeSpace(props.gap, theme.space.small), gap: normalizeSpace(props.gap, theme.space.small),
overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues 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, flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,
minWidth: props.grow === 1 ? 0 : undefined, minWidth: props.grow === 1 ? 0 : undefined,
}, },
'> :nth-child(2)': { '>:nth-of-type(2)': {
flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle, flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle,
minWidth: props.grow === 2 ? 0 : undefined, minWidth: props.grow === 2 ? 0 : undefined,
}, },

View File

@@ -16,6 +16,7 @@ import React, {
RefObject, RefObject,
MutableRefObject, MutableRefObject,
CSSProperties, CSSProperties,
useEffect,
} from 'react'; } from 'react';
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow'; import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
import {DataSource} from '../../state/datasource/DataSource'; import {DataSource} from '../../state/datasource/DataSource';
@@ -23,7 +24,7 @@ import {Layout} from '../Layout';
import {TableHead} from './TableHead'; import {TableHead} from './TableHead';
import {Percentage} from '../../utils/widthUtils'; import {Percentage} from '../../utils/widthUtils';
import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer'; import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
import {useDataTableManager, TableManager} from './useDataTableManager'; import {useDataTableManager, DataTableManager} from './useDataTableManager';
import {TableSearch} from './TableSearch'; import {TableSearch} from './TableSearch';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {theme} from '../theme'; import {theme} from '../theme';
@@ -40,7 +41,7 @@ interface DataTableProps<T = any> {
onSelect?(record: T | undefined, records: T[]): void; onSelect?(record: T | undefined, records: T[]): void;
onRowStyle?(record: T): CSSProperties | undefined; onRowStyle?(record: T): CSSProperties | undefined;
// multiselect?: true // 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 _testHeight?: number; // exposed for unit testing only
} }
@@ -92,7 +93,9 @@ export function DataTable<T extends object>(
props.onSelect, props.onSelect,
); );
if (props.tableManagerRef) { if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager; (props.tableManagerRef as MutableRefObject<
DataTableManager<T>
>).current = tableManager;
} }
const { const {
visibleColumns, visibleColumns,
@@ -246,6 +249,17 @@ export function DataTable<T extends object>(
return <EmptyTable dataSource={dataSource} />; return <EmptyTable dataSource={dataSource} />;
}, []); }, []);
useEffect(
function cleanup() {
return () => {
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<undefined>).current = undefined;
}
};
},
[props.tableManagerRef],
);
return ( return (
<Layout.Container grow> <Layout.Container grow>
<Layout.Top> <Layout.Top>

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
import {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable'; import {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
import {Percentage} from '../../utils/widthUtils'; import {Percentage} from '../../utils/widthUtils';
import produce from 'immer'; 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 {DataSource} from '../../state/datasource/DataSource';
import {useMemoize} from '../../utils/useMemoize'; import {useMemoize} from '../../utils/useMemoize';
@@ -20,9 +20,45 @@ export type Sorting = {
direction: Exclude<SortDirection, undefined>; 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}; type Selection = {items: ReadonlySet<number>; current: number};
@@ -38,15 +74,13 @@ export function useDataTableManager<T>(
dataSource: DataSource<T>, dataSource: DataSource<T>,
defaultColumns: DataTableColumn<T>[], defaultColumns: DataTableColumn<T>[],
onSelect?: (item: T | undefined, items: T[]) => void, onSelect?: (item: T | undefined, items: T[]) => void,
) { ): DataTableManager<T> {
const [columns, setEffectiveColumns] = useState( const [columns, setEffectiveColumns] = useState(
computeInitialColumns(defaultColumns), computeInitialColumns(defaultColumns),
); );
// TODO: move selection with shifts with index < selection? // TODO: move selection with shifts with index < selection?
// TODO: clear selection if out of range // TODO: clear selection if out of range
const [selection, setSelection] = useState<Selection>(emptySelection); 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 [sorting, setSorting] = useState<Sorting | undefined>(undefined);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
@@ -86,21 +120,22 @@ export function useDataTableManager<T>(
[], [],
); );
// N.B: we really want to have stable refs for these functions, const clearSelection = useCallback(() => {
// to avoid that all context menus need re-render for every selection change, setSelection(emptySelection);
// hence the selectionRef hack }, []);
const getSelectedItem = useCallback(() => { const getSelectedItem = useCallback(() => {
return selectionRef.current.current < 0 return selection.current < 0
? undefined ? undefined
: dataSource.getItem(selectionRef.current.current); : dataSource.getItem(selection.current);
}, [dataSource]); }, [dataSource, selection]);
const getSelectedItems = useCallback(() => { const getSelectedItems = useCallback(() => {
return [...selectionRef.current.items] return [...selection.items]
.sort() .sort()
.map((i) => dataSource.getItem(i)) .map((i) => dataSource.getItem(i))
.filter(Boolean) as any[]; .filter(Boolean) as any[];
}, [dataSource]); }, [dataSource, selection]);
useEffect( useEffect(
function fireSelection() { function fireSelection() {
@@ -221,7 +256,7 @@ export function useDataTableManager<T>(
dataSource.setSortBy(key as any); dataSource.setSortBy(key as any);
} }
if (!sorting || sorting.direction !== direction) { if (!sorting || sorting.direction !== direction) {
dataSource.setReversed(direction === 'up'); dataSource.setReversed(direction === 'desc');
} }
setSorting({key, direction}); setSorting({key, direction});
} }
@@ -271,6 +306,7 @@ export function useDataTableManager<T>(
selection, selection,
selectItem, selectItem,
addRangeToSelection, addRangeToSelection,
clearSelection,
getSelectedItem, getSelectedItem,
getSelectedItems, getSelectedItems,
/** Changing column filters */ /** Changing column filters */

View File

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