({
+const SidebarInteractiveContainer = styled(_Interactive)<_InteractiveProps>({
flex: 'none',
});
SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer';
diff --git a/desktop/app/src/ui/components/table/TableHead.tsx b/desktop/app/src/ui/components/table/TableHead.tsx
index dfd3ed396..214e78312 100644
--- a/desktop/app/src/ui/components/table/TableHead.tsx
+++ b/desktop/app/src/ui/components/table/TableHead.tsx
@@ -18,7 +18,7 @@ import {
import {normaliseColumnWidth, isPercentage} from './utils';
import {PureComponent} from 'react';
import ContextMenu from '../ContextMenu';
-import Interactive, {InteractiveProps} from '../Interactive';
+import {_Interactive, _InteractiveProps} from 'flipper-plugin';
import styled from '@emotion/styled';
import {colors} from '../colors';
import FlexRow from '../FlexRow';
@@ -31,7 +31,7 @@ const TableHeaderArrow = styled.span({
});
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
-const TableHeaderColumnInteractive = styled(Interactive)
({
+const TableHeaderColumnInteractive = styled(_Interactive)<_InteractiveProps>({
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',
diff --git a/desktop/app/src/ui/index.tsx b/desktop/app/src/ui/index.tsx
index 01a233602..f0a4332a3 100644
--- a/desktop/app/src/ui/index.tsx
+++ b/desktop/app/src/ui/index.tsx
@@ -75,7 +75,6 @@ export {default as ErrorBoundary} from './components/ErrorBoundary';
// interactive components
export {OrderableOrder} from './components/Orderable';
-export {default as Interactive} from './components/Interactive';
export {default as Orderable} from './components/Orderable';
export {default as VirtualList} from './components/VirtualList';
diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts
index d78028934..f42ec4cb8 100644
--- a/desktop/flipper-plugin/src/index.ts
+++ b/desktop/flipper-plugin/src/index.ts
@@ -75,6 +75,11 @@ export {createDataSource, DataSource} from './state/datasource/DataSource';
export {DataTable, DataTableColumn} from './ui/datatable/DataTable';
+export {
+ Interactive as _Interactive,
+ InteractiveProps as _InteractiveProps,
+} from './ui/Interactive';
+
// It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors.
// Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)
diff --git a/desktop/app/src/ui/components/Interactive.tsx b/desktop/flipper-plugin/src/ui/Interactive.tsx
similarity index 97%
rename from desktop/app/src/ui/components/Interactive.tsx
rename to desktop/flipper-plugin/src/ui/Interactive.tsx
index 531dd99fc..78698cebf 100644
--- a/desktop/app/src/ui/components/Interactive.tsx
+++ b/desktop/flipper-plugin/src/ui/Interactive.tsx
@@ -7,17 +7,16 @@
* @format
*/
-import {Rect} from '../../utils/geometry';
-import LowPassFilter from '../../utils/LowPassFilter';
+import styled from '@emotion/styled';
+import React from 'react';
+import LowPassFilter from './utils/LowPassFilter';
import {
getDistanceTo,
maybeSnapLeft,
maybeSnapTop,
SNAP_SIZE,
-} from '../../utils/snap';
-import styled from '@emotion/styled';
-import invariant from 'invariant';
-import React from 'react';
+} from './utils/snap';
+import type {Rect} from './utils/Rect';
const WINDOW_CURSOR_BOUNDARY = 5;
@@ -96,7 +95,7 @@ const InteractiveContainer = styled.div({
});
InteractiveContainer.displayName = 'Interactive:InteractiveContainer';
-export default class Interactive extends React.Component<
+export class Interactive extends React.Component<
InteractiveProps,
InteractiveState
> {
@@ -344,9 +343,6 @@ export default class Interactive extends React.Component<
calculateMove(event: MouseEvent) {
const {movingInitialCursor, movingInitialProps} = this.state;
- invariant(movingInitialProps, 'TODO');
- invariant(movingInitialCursor, 'TODO');
-
const {clientX: cursorLeft, clientY: cursorTop} = event;
const movedLeft = movingInitialCursor!.left - cursorLeft;
@@ -414,10 +410,6 @@ export default class Interactive extends React.Component<
resizingSides,
} = this.state;
- invariant(resizingInitialRect, 'TODO');
- invariant(resizingInitialCursor, 'TODO');
- invariant(resizingSides, 'TODO');
-
const deltaLeft = resizingInitialCursor!.left - event.clientX;
const deltaTop = resizingInitialCursor!.top - event.clientY;
@@ -503,7 +495,7 @@ export default class Interactive extends React.Component<
getRect(): Rect {
const {props, ref} = this;
- invariant(ref, 'expected ref');
+ if (!ref) throw new Error('expected ref');
return {
height: ref!.offsetHeight || 0,
diff --git a/desktop/app/src/utils/__tests__/LowPassFilter.node.tsx b/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx
similarity index 95%
rename from desktop/app/src/utils/__tests__/LowPassFilter.node.tsx
rename to desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx
index 44538775a..a897b3fe6 100644
--- a/desktop/app/src/utils/__tests__/LowPassFilter.node.tsx
+++ b/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx
@@ -7,7 +7,7 @@
* @format
*/
-import LowPassFilter from '../LowPassFilter';
+import LowPassFilter from '../utils/LowPassFilter';
test('hasFullBuffer', () => {
const lpf = new LowPassFilter();
diff --git a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx
new file mode 100644
index 000000000..0ae7c78ba
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx
@@ -0,0 +1,261 @@
+/**
+ * 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 React, {
+ memo,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useLayoutEffect,
+} from 'react';
+import {DataSource} from '../../state/datasource/DataSource';
+import {useVirtual} from 'react-virtual';
+import styled from '@emotion/styled';
+
+// how fast we update if updates are low-prio (e.g. out of window and not super significant)
+const DEBOUNCE = 500; //ms
+
+enum UpdatePrio {
+ NONE,
+ LOW,
+ HIGH,
+}
+
+type DataSourceProps = {
+ /**
+ * The data source to render
+ */
+ dataSource: DataSource;
+ /**
+ * Automatically scroll if the user is near the end?
+ */
+ autoScroll?: boolean;
+ /**
+ * additional context that will be passed verbatim to the itemRenderer, so that it can be easily memoized
+ */
+ context?: C;
+ /**
+ * Takes care of rendering an item
+ * @param item The item as stored in the dataSource
+ * @param index The index of the item being rendered. The index represents the offset in the _visible_ items of the dataSource
+ * @param context The optional context passed into this DataSourceRenderer
+ */
+ itemRenderer(item: T, index: number, context: C): React.ReactElement;
+ useFixedRowHeight: boolean;
+ defaultRowHeight: number;
+ _testHeight?: number; // exposed for unit testing only
+};
+
+/**
+ * This component is UI agnostic, and just takes care of virtualizing the provided dataSource, and render it as efficiently a possibible,
+ * de priorizing off screen updates etc.
+ */
+export const DataSourceRenderer: (
+ props: DataSourceProps,
+) => React.ReactElement = memo(function DataSourceRenderer({
+ dataSource,
+ defaultRowHeight,
+ useFixedRowHeight,
+ context,
+ itemRenderer,
+ autoScroll,
+ _testHeight,
+}: DataSourceProps) {
+ /**
+ * Virtualization
+ */
+ // render scheduling
+ const renderPending = useRef(UpdatePrio.NONE);
+ const lastRender = useRef(Date.now());
+ const setForceUpdate = useState(0)[1];
+ const forceHeightRecalculation = useRef(0);
+
+ const parentRef = React.useRef(null);
+
+ const virtualizer = useVirtual({
+ size: dataSource.output.length,
+ parentRef,
+ useObserver: _testHeight
+ ? () => ({height: _testHeight, width: 1000})
+ : undefined,
+ // eslint-disable-next-line
+ estimateSize: useCallback(() => defaultRowHeight, [forceHeightRecalculation.current, defaultRowHeight]),
+ overscan: 0,
+ });
+
+ useEffect(
+ function subscribeToDataSource() {
+ const forceUpdate = () => {
+ if (unmounted) {
+ return;
+ }
+ setForceUpdate((x) => x + 1);
+ };
+
+ let unmounted = false;
+ let timeoutHandle: NodeJS.Timeout | undefined = undefined;
+
+ function rerender(prio: 1 | 2, invalidateHeights = false) {
+ if (invalidateHeights && !useFixedRowHeight) {
+ // the height of some existing rows might have changed
+ forceHeightRecalculation.current++;
+ }
+ if (_testHeight) {
+ // test environment, update immediately
+ forceUpdate();
+ return;
+ }
+ if (renderPending.current >= prio) {
+ // already scheduled an update with equal or higher prio
+ return;
+ }
+ renderPending.current = prio;
+ if (prio === UpdatePrio.LOW) {
+ // TODO: make DEBOUNCE depend on how big the relative change is
+ timeoutHandle = setTimeout(forceUpdate, DEBOUNCE);
+ } else {
+ // High
+ if (timeoutHandle) {
+ clearTimeout(timeoutHandle);
+ }
+ requestAnimationFrame(forceUpdate);
+ }
+ }
+
+ dataSource.setOutputChangeListener((event) => {
+ switch (event.type) {
+ case 'reset':
+ rerender(UpdatePrio.HIGH, true);
+ break;
+ case 'shift':
+ if (event.location === 'in') {
+ rerender(UpdatePrio.HIGH, false);
+ } else {
+ // optimization: we don't want to listen to every count change, especially after window
+ // and in some cases before window
+ rerender(UpdatePrio.LOW, false);
+ }
+ break;
+ case 'update':
+ // in visible range, so let's force update
+ rerender(UpdatePrio.HIGH, true);
+ break;
+ }
+ });
+
+ return () => {
+ unmounted = true;
+ dataSource.setOutputChangeListener(undefined);
+ };
+ },
+ [dataSource, setForceUpdate, useFixedRowHeight, _testHeight],
+ );
+
+ useEffect(() => {
+ // initial virtualization is incorrect because the parent ref is not yet set, so trigger render after mount
+ setForceUpdate((x) => x + 1);
+ }, [setForceUpdate]);
+
+ useLayoutEffect(function updateWindow() {
+ const start = virtualizer.virtualItems[0]?.index ?? 0;
+ const end = start + virtualizer.virtualItems.length;
+ dataSource.setWindow(start, end);
+ });
+
+ /**
+ * Scrolling
+ */
+ // if true, scroll if new items are appended
+ const followOutput = useRef(false);
+ // if true, the next scroll event will be fired as result of a size change,
+ // ignore it
+ const suppressScroll = useRef(false);
+ suppressScroll.current = true;
+
+ const onScroll = useCallback(() => {
+ // scroll event is firing as a result of painting new items?
+ if (suppressScroll.current || !autoScroll) {
+ return;
+ }
+ const elem = parentRef.current!;
+ // make bottom 1/3 of screen sticky
+ if (elem.scrollTop < elem.scrollHeight - elem.clientHeight * 1.3) {
+ followOutput.current = false;
+ } else {
+ followOutput.current = true;
+ }
+ }, [autoScroll]);
+
+ useLayoutEffect(function scrollToEnd() {
+ if (followOutput.current) {
+ virtualizer.scrollToIndex(
+ dataSource.output.length - 1,
+ /* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
+ {
+ align: 'end',
+ behavior: 'smooth',
+ } as any,
+ );
+ }
+ });
+
+ /**
+ * Render finalization
+ */
+ useEffect(function renderCompleted() {
+ suppressScroll.current = false;
+ renderPending.current = UpdatePrio.NONE;
+ lastRender.current = Date.now();
+ });
+
+ /**
+ * Rendering
+ */
+ return (
+
+
+ {virtualizer.virtualItems.map((virtualRow) => (
+ // the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always.
+ // Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
+
+ {itemRenderer(
+ dataSource.getItem(virtualRow.index),
+ virtualRow.index,
+ context,
+ )}
+
+ ))}
+
+
+ );
+}) as any;
+
+const TableContainer = styled.div({
+ overflowY: 'scroll',
+ overflowX: 'hidden',
+ display: 'flex',
+ flex: 1,
+});
+
+const TableWindow = styled.div<{height: number}>(({height}) => ({
+ height,
+ position: 'relative',
+ width: '100%',
+}));
diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx
index 006acd106..09947f1dc 100644
--- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx
+++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx
@@ -7,266 +7,90 @@
* @format
*/
-import React, {
- memo,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- useLayoutEffect,
-} from 'react';
+import React, {MutableRefObject, RefObject, useMemo} from 'react';
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
-import {Property} from 'csstype';
import {DataSource} from '../../state/datasource/DataSource';
-import {useVirtual} from 'react-virtual';
-import styled from '@emotion/styled';
-import {theme} from '../theme';
+import {Layout} from '../Layout';
+import {TableHead} from './TableHead';
+import {Percentage} from '../utils/widthUtils';
+import {DataSourceRenderer} from './DataSourceRenderer';
+import {useDataTableManager, TableManager} from './useDataTableManager';
-// how fast we update if updates are low-prio (e.g. out of window and not super significant)
-const DEBOUNCE = 500; //ms
-
-interface DataTableProps {
+interface DataTableProps {
columns: DataTableColumn[];
dataSource: DataSource;
- zebra?: boolean;
autoScroll?: boolean;
+ tableManagerRef?: RefObject;
+ _testHeight?: number; // exposed for unit testing only
}
-export type DataTableColumn = (
- | {
- // existing data
- key: keyof T;
- }
- | {
- // derived data / custom rendering
- key: string;
- onRender?: (row: T) => React.ReactNode;
- }
-) & {
- label?: string;
- width?: number | '*';
+export type DataTableColumn = {
+ key: keyof T & string;
+ // possible future extension: getValue(row) (and free-form key) to support computed columns
+ onRender?: (row: T) => React.ReactNode;
+ title?: string;
+ width?: number | Percentage | undefined; // undefined: use all remaining width
wrap?: boolean;
- align?: Property.JustifyContent;
- defaultVisible?: boolean;
+ align?: 'left' | 'right' | 'center';
+ visible?: boolean;
};
-export interface RenderingConfig {
+export interface RenderingConfig {
columns: DataTableColumn[];
- zebra: boolean;
- onMouseDown: (e: React.MouseEvent, row: T) => void;
- onMouseEnter: (e: React.MouseEvent, row: T) => void;
}
-enum UpdatePrio {
- NONE,
- LOW,
- HIGH,
-}
-
-export const DataTable: (
- props: DataTableProps,
-) => React.ReactElement = memo(function DataSourceRenderer(
- props: DataTableProps,
-) {
- const {dataSource} = props;
+export function DataTable(props: DataTableProps) {
+ const tableManager = useDataTableManager(props.dataSource, props.columns);
+ if (props.tableManagerRef) {
+ (props.tableManagerRef as MutableRefObject).current = tableManager;
+ }
const renderingConfig = useMemo(() => {
return {
- columns: props.columns,
- zebra: props.zebra !== false,
- onMouseDown() {
- // TODO:
- },
- onMouseEnter() {
- // TODO:
- },
+ columns: tableManager.visibleColumns,
};
- }, [props.columns, props.zebra]);
+ }, [tableManager.visibleColumns]);
- const usesWrapping = useMemo(() => props.columns.some((col) => col.wrap), [
- props.columns,
- ]);
-
- /**
- * Virtualization
- */
- // render scheduling
- const renderPending = useRef(UpdatePrio.NONE);
- const lastRender = useRef(Date.now());
- const setForceUpdate = useState(0)[1];
-
- const parentRef = React.useRef(null);
-
- const virtualizer = useVirtual({
- size: dataSource.output.length,
- parentRef,
- // eslint-disable-next-line
- estimateSize: useCallback(() => DEFAULT_ROW_HEIGHT, []),
- overscan: 0,
- });
-
- useEffect(
- function subscribeToDataSource() {
- const forceUpdate = () => {
- if (unmounted) {
- return;
- }
- setForceUpdate((x) => x + 1);
- };
-
- let unmounted = false;
- let timeoutHandle: NodeJS.Timeout | undefined = undefined;
-
- function rerender(prio: 1 | 2) {
- if (renderPending.current >= prio) {
- // already scheduled an update with equal or higher prio
- return;
- }
- renderPending.current = prio;
- if (prio === UpdatePrio.LOW) {
- // TODO: make DEBOUNCE depend on how big the relative change is
- timeoutHandle = setTimeout(forceUpdate, DEBOUNCE);
- } else {
- // High
- if (timeoutHandle) {
- clearTimeout(timeoutHandle);
- }
- requestAnimationFrame(forceUpdate);
- }
- }
-
- dataSource.setOutputChangeListener((event) => {
- switch (event.type) {
- case 'reset':
- rerender(UpdatePrio.HIGH);
- break;
- case 'shift':
- // console.log(event.type, event.location);
- if (event.location === 'in') {
- rerender(UpdatePrio.HIGH);
- } else {
- // optimization: we don't want to listen to every count change, especially after window
- // and in some cases before window
- rerender(UpdatePrio.LOW);
- }
- break;
- case 'update':
- // in visible range, so let's force update
- rerender(UpdatePrio.HIGH);
- break;
- }
- });
-
- return () => {
- unmounted = true;
- dataSource.setOutputChangeListener(undefined);
- };
- },
- [dataSource, setForceUpdate],
+ const usesWrapping = useMemo(
+ () => tableManager.columns.some((col) => col.wrap),
+ [tableManager.columns],
);
- useLayoutEffect(function updateWindow() {
- const start = virtualizer.virtualItems[0]?.index ?? 0;
- const end = start + virtualizer.virtualItems.length;
- dataSource.setWindow(start, end);
- });
-
- /**
- * Scrolling
- */
- // if true, scroll if new items are appended
- const followOutput = useRef(false);
- // if true, the next scroll event will be fired as result of a size change,
- // ignore it
- const suppressScroll = useRef(false);
- suppressScroll.current = true;
-
- const onScroll = useCallback(() => {
- // scroll event is firing as a result of painting new items?
- if (suppressScroll.current) {
- return;
- }
- const elem = parentRef.current!;
- // make bottom 1/3 of screen sticky
- if (elem.scrollTop < elem.scrollHeight - elem.clientHeight * 1.3) {
- followOutput.current = false;
- } else {
- followOutput.current = true;
- }
- }, []);
-
- useLayoutEffect(function scrollToEnd() {
- if (followOutput.current) {
- virtualizer.scrollToIndex(
- dataSource.output.length - 1,
- /* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
- {
- align: 'end',
- behavior: 'smooth',
- } as any,
- );
- }
- });
-
- /**
- * Render finalization
- */
- useEffect(function renderCompleted() {
- suppressScroll.current = false;
- renderPending.current = UpdatePrio.NONE;
- lastRender.current = Date.now();
- });
-
- /**
- * Rendering
- */
return (
-
-
- {virtualizer.virtualItems.map((virtualRow) => (
- // the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always.
- // Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
-
- ))}
-
-
+
+
+
+ dataSource={props.dataSource}
+ autoScroll={props.autoScroll}
+ useFixedRowHeight={!usesWrapping}
+ defaultRowHeight={DEFAULT_ROW_HEIGHT}
+ context={renderingConfig}
+ itemRenderer={itemRenderer}
+ _testHeight={props._testHeight}
+ />
+
);
-}) as any;
+}
-const TableContainer = styled.div({
- overflowY: 'scroll',
- overflowX: 'hidden',
- display: 'flex',
- flex: 1,
-});
+export type RenderContext = {
+ columns: DataTableColumn[];
+};
-const TableWindow = styled.div<{height: number}>(({height}) => ({
- height,
- position: 'relative',
- width: '100%',
-}));
+function itemRenderer(item: any, index: number, renderContext: RenderContext) {
+ return (
+
+ );
+}
diff --git a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx
new file mode 100644
index 000000000..fb5f41a9e
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx
@@ -0,0 +1,254 @@
+/**
+ * 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 {
+ calculatePercentage,
+ isPercentage,
+ Percentage,
+ Width,
+} from '../utils/widthUtils';
+import {useRef} from 'react';
+import {Interactive, InteractiveProps} from '../Interactive';
+import styled from '@emotion/styled';
+import React from 'react';
+import {theme} from '../theme';
+import type {DataTableColumn} from './DataTable';
+
+import {Button, Checkbox, Dropdown, Menu, Typography} from 'antd';
+import {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons';
+import {Layout} from '../Layout';
+import {Sorting, OnColumnResize} from './useDataTableManager';
+
+const {Text} = Typography;
+
+const TableHeaderArrow = styled.span({
+ float: 'right',
+});
+TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
+
+function SortIcons({direction}: {direction?: 'up' | 'down'}) {
+ return (
+
+
+
+
+ );
+}
+
+const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
+ ({direction}) => ({
+ visibility: direction === undefined ? 'hidden' : undefined,
+ display: 'inline-flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ marginLeft: 4,
+ color: theme.disabledColor,
+ }),
+);
+
+const TableHeaderColumnInteractive = styled(Interactive)({
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ width: '100%',
+});
+TableHeaderColumnInteractive.displayName =
+ 'TableHead:TableHeaderColumnInteractive';
+
+const TableHeaderColumnContainer = styled(Layout.Horizontal)({
+ padding: '4px 8px',
+ ':hover': {
+ backgroundColor: theme.buttonDefaultBackground,
+ },
+ [`:hover ${SortIconsContainer}`]: {
+ visibility: 'visible',
+ },
+});
+TableHeaderColumnContainer.displayName = 'TableHead:TableHeaderColumnContainer';
+
+const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'row',
+ borderBottom: `1px solid ${theme.dividerColor}`,
+ backgroundColor: theme.backgroundWash,
+ userSelect: 'none',
+});
+TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
+
+const TableHeadColumnContainer = styled.div<{
+ width: Width;
+}>((props) => ({
+ flexShrink: props.width === undefined ? 1 : 0,
+ flexGrow: props.width === undefined ? 1 : 0,
+ width: props.width === undefined ? '100%' : props.width,
+}));
+TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
+
+const RIGHT_RESIZABLE = {right: true};
+
+function TableHeadColumn({
+ id,
+ title,
+ width,
+ isResizable,
+ isSortable,
+ onColumnResize,
+ isSortable: sortable,
+ onSort,
+ sorted,
+}: {
+ id: string;
+ width: Width;
+ isSortable?: boolean;
+ sorted: 'up' | 'down' | undefined;
+ isResizable: boolean;
+ onSort: (id: string) => void;
+ sortOrder: undefined | Sorting;
+ onColumnResize: OnColumnResize;
+ title?: string;
+}) {
+ const ref = useRef(null);
+
+ const onResize = (newWidth: number) => {
+ if (!isResizable) {
+ return;
+ }
+
+ let normalizedWidth: number | Percentage = newWidth;
+
+ // normalise number to a percentage if we were originally passed a percentage
+ if (isPercentage(width) && ref.current) {
+ const {parentElement} = ref.current;
+ const parentWidth = parentElement!.clientWidth;
+ const {childNodes} = parentElement!;
+
+ const lastElem = childNodes[childNodes.length - 1];
+ const right =
+ lastElem instanceof HTMLElement
+ ? lastElem.offsetLeft + lastElem.clientWidth + 1
+ : 0;
+
+ if (right < parentWidth) {
+ normalizedWidth = calculatePercentage(parentWidth, newWidth);
+ }
+ }
+
+ onColumnResize(id, normalizedWidth);
+ };
+
+ let children = (
+
+ {title}
+ {isSortable && }
+
+ );
+
+ if (isResizable) {
+ children = (
+
+ {children}
+
+ );
+ }
+
+ return (
+ onSort(id) : undefined}
+ ref={ref}>
+ {children}
+
+ );
+}
+
+export function TableHead({
+ columns,
+ visibleColumns,
+ ...props
+}: {
+ columns: DataTableColumn[];
+ visibleColumns: DataTableColumn[];
+ onColumnResize: OnColumnResize;
+ onColumnToggleVisibility: (key: string) => void;
+ onReset: () => void;
+ sorting: Sorting | undefined;
+ onColumnSort: (key: string) => void;
+}) {
+ const menu = (
+
+ );
+
+ return (
+
+ {visibleColumns.map((column, i) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+const SettingsButton = styled(Button)({
+ padding: 4,
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ backgroundColor: theme.backgroundWash,
+});
diff --git a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx
index 1c59ae6df..e63b751dd 100644
--- a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx
+++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx
@@ -9,9 +9,9 @@
import React, {memo} from 'react';
import styled from '@emotion/styled';
-import {Property} from 'csstype';
import {theme} from 'flipper-plugin';
-import {RenderingConfig} from './DataTable';
+import type {RenderContext} from './DataTable';
+import {Width} from '../utils/widthUtils';
// heuristic for row estimation, should match any future styling updates
export const DEFAULT_ROW_HEIGHT = 24;
@@ -32,7 +32,7 @@ const TableBodyRowContainer = styled.div(
display: 'flex',
flexDirection: 'row',
backgroundColor: backgroundColor(props),
- color: props.highlighted ? theme.white : theme.primaryColor,
+ color: props.highlighted ? theme.white : theme.textColorPrimary,
'& *': {
color: props.highlighted ? `${theme.white} !important` : undefined,
},
@@ -50,25 +50,26 @@ const TableBodyRowContainer = styled.div(
TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
const TableBodyColumnContainer = styled.div<{
- width?: any;
+ width: Width;
multiline?: boolean;
- justifyContent: Property.JustifyContent;
+ justifyContent: 'left' | 'right' | 'center' | 'flex-start';
}>((props) => ({
display: 'flex',
- flexShrink: props.width === 'flex' ? 1 : 0,
- flexGrow: props.width === 'flex' ? 1 : 0,
+ flexShrink: props.width === undefined ? 1 : 0,
+ flexGrow: props.width === undefined ? 1 : 0,
overflow: 'hidden',
padding: `0 ${theme.space.small}px`,
verticalAlign: 'top',
whiteSpace: props.multiline ? 'normal' : 'nowrap',
wordWrap: props.multiline ? 'break-word' : 'normal',
- width: props.width === 'flex' ? undefined : props.width,
+ width: props.width,
justifyContent: props.justifyContent,
+ borderBottom: `1px solid ${theme.dividerColor}`,
}));
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
type Props = {
- config: RenderingConfig;
+ config: RenderContext;
highlighted: boolean;
row: any;
};
@@ -76,47 +77,31 @@ type Props = {
export const TableRow = memo(function TableRow(props: Props) {
const {config, highlighted, row} = props;
return (
-
- {config.columns.map((col) => {
- const value = (col as any).onRender
- ? (col as any).onRender(row)
- : (row as any)[col.key] ?? '';
+
+ {config.columns
+ .filter((col) => col.visible)
+ .map((col) => {
+ let value = (col as any).onRender
+ ? (col as any).onRender(row)
+ : (row as any)[col.key] ?? '';
+ if (typeof value === 'boolean') {
+ value = value ? 'true' : 'false';
+ }
- return (
-
- {value}
-
- );
- })}
+ return (
+
+ {value}
+
+ );
+ })}
);
});
-
-function normaliseColumnWidth(
- width: string | number | null | undefined | '*',
-): number | string {
- if (width == null || width === '*') {
- // default
- return 'flex';
- }
-
- if (isPercentage(width)) {
- // percentage eg. 50%
- return width;
- }
-
- if (typeof width === 'number') {
- // pixel width
- return width;
- }
-
- throw new TypeError(`Unknown value ${width} for table column width`);
-}
-
-function isPercentage(width: any): boolean {
- return typeof width === 'string' && width[width.length - 1] === '%';
-}
diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx
new file mode 100644
index 000000000..174f7e6e9
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx
@@ -0,0 +1,224 @@
+/**
+ * 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 React, {createRef} from 'react';
+import {DataTable, DataTableColumn} from '../DataTable';
+import {render, act} from '@testing-library/react';
+import {createDataSource} from '../../../state/datasource/DataSource';
+import {TableManager} from '../useDataTableManager';
+
+type Todo = {
+ title: string;
+ done: boolean;
+};
+
+function createTestDataSource() {
+ return createDataSource([
+ {
+ title: 'test DataTable',
+ done: true,
+ },
+ ]);
+}
+
+const columns: DataTableColumn[] = [
+ {
+ key: 'title',
+ wrap: false,
+ },
+ {
+ key: 'done',
+ wrap: false,
+ },
+];
+
+test('update and append', async () => {
+ const ds = createTestDataSource();
+ const ref = createRef();
+ const rendering = render(
+ ,
+ );
+ {
+ const elem = await rendering.findAllByText('test DataTable');
+ expect(elem.length).toBe(1);
+ expect(elem[0].parentElement).toMatchInlineSnapshot(`
+
+
+ test DataTable
+
+
+ true
+
+
+ `);
+ }
+
+ act(() => {
+ ds.append({
+ title: 'Drink coffee',
+ done: false,
+ });
+ });
+ {
+ const elem = await rendering.findAllByText('Drink coffee');
+ expect(elem.length).toBe(1);
+ }
+
+ // update
+ act(() => {
+ ds.update(0, {
+ title: 'DataTable tested',
+ done: false,
+ });
+ });
+ {
+ const elem = await rendering.findAllByText('DataTable tested');
+ expect(elem.length).toBe(1);
+ expect(rendering.queryByText('test DataTable')).toBeNull();
+ }
+});
+
+test('column visibility', async () => {
+ const ds = createTestDataSource();
+ const ref = createRef();
+ const rendering = render(
+ ,
+ );
+ {
+ const elem = await rendering.findAllByText('test DataTable');
+ expect(elem.length).toBe(1);
+ expect(elem[0].parentElement).toMatchInlineSnapshot(`
+
+
+ test DataTable
+
+
+ true
+
+
+ `);
+ }
+
+ // hide done
+ act(() => {
+ ref.current?.toggleColumnVisibility('done');
+ });
+ {
+ const elem = await rendering.findAllByText('test DataTable');
+ expect(elem.length).toBe(1);
+ expect(elem[0].parentElement).toMatchInlineSnapshot(`
+
+ `);
+ }
+
+ // reset
+ act(() => {
+ ref.current?.reset();
+ });
+ {
+ const elem = await rendering.findAllByText('test DataTable');
+ expect(elem.length).toBe(1);
+ expect(elem[0].parentElement?.children.length).toBe(2);
+ }
+});
+
+test('sorting', async () => {
+ const ds = createTestDataSource();
+ ds.clear();
+ ds.append({
+ title: 'item a',
+ done: false,
+ });
+ ds.append({
+ title: 'item x',
+ done: false,
+ });
+ ds.append({
+ title: 'item b',
+ done: false,
+ });
+ const ref = createRef();
+ const rendering = render(
+ ,
+ );
+ // insertion order
+ {
+ const elem = await rendering.findAllByText(/item/);
+ expect(elem.length).toBe(3);
+ expect(elem.map((e) => e.textContent)).toEqual([
+ 'item a',
+ 'item x',
+ 'item b',
+ ]);
+ }
+ // sort asc
+ act(() => {
+ ref.current?.sortColumn('title');
+ });
+ {
+ const elem = await rendering.findAllByText(/item/);
+ expect(elem.length).toBe(3);
+ expect(elem.map((e) => e.textContent)).toEqual([
+ 'item a',
+ 'item b',
+ 'item x',
+ ]);
+ }
+ // sort desc
+ act(() => {
+ ref.current?.sortColumn('title');
+ });
+ {
+ const elem = await rendering.findAllByText(/item/);
+ expect(elem.length).toBe(3);
+ expect(elem.map((e) => e.textContent)).toEqual([
+ 'item x',
+ 'item b',
+ 'item a',
+ ]);
+ }
+ // another click resets again
+ act(() => {
+ ref.current?.sortColumn('title');
+ });
+ {
+ const elem = await rendering.findAllByText(/item/);
+ expect(elem.length).toBe(3);
+ expect(elem.map((e) => e.textContent)).toEqual([
+ 'item a',
+ 'item x',
+ 'item b',
+ ]);
+ }
+});
diff --git a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx
new file mode 100644
index 000000000..acb272776
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx
@@ -0,0 +1,116 @@
+/**
+ * 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 {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
+import {Percentage} from '../utils/widthUtils';
+import produce from 'immer';
+import {useCallback, useMemo, useState} from 'react';
+import {DataSource} from '../../state/datasource/DataSource';
+
+export type OnColumnResize = (id: string, size: number | Percentage) => void;
+export type Sorting = {
+ key: string;
+ direction: 'up' | 'down';
+};
+
+export type TableManager = ReturnType;
+
+/**
+ * A hook that coordinates filtering, sorting etc for a DataSource
+ */
+export function useDataTableManager(
+ dataSource: DataSource,
+ defaultColumns: DataTableColumn[],
+) {
+ // TODO: restore from local storage
+ const [columns, setEffectiveColumns] = useState(
+ computeInitialColumns(defaultColumns),
+ );
+ const [sorting, setSorting] = useState(undefined);
+ const visibleColumns = useMemo(
+ () => columns.filter((column) => column.visible),
+ [columns],
+ );
+
+ const reset = useCallback(() => {
+ setEffectiveColumns(computeInitialColumns(defaultColumns));
+ setSorting(undefined);
+ dataSource.reset();
+ // TODO: local storage
+ }, [dataSource, defaultColumns]);
+
+ const resizeColumn = useCallback((id: string, width: number | Percentage) => {
+ setEffectiveColumns(
+ // TODO: fix typing of produce
+ produce((columns: DataTableColumn[]) => {
+ const col = columns.find((c) => c.key === id)!;
+ col.width = width;
+ }),
+ );
+ }, []);
+
+ 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);
+ dataSource.setReversed(false);
+ }
+ },
+ [dataSource, sorting],
+ );
+
+ const toggleColumnVisibility = useCallback((id: string) => {
+ setEffectiveColumns(
+ // TODO: fix typing of produce
+ produce((columns: DataTableColumn[]) => {
+ const col = columns.find((c) => c.key === id)!;
+ col.visible = !col.visible;
+ }),
+ );
+ }, []);
+
+ return {
+ /** The default columns, but normalized */
+ columns,
+ /** The effective columns to be rendererd */
+ visibleColumns,
+ /** The currently applicable sorting, if any */
+ sorting,
+ /** Reset the current table preferences, including column widths an visibility, back to the default */
+ reset,
+ /** Resizes the column with the given key to the given width */
+ resizeColumn,
+ /** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
+ sortColumn,
+ /** Show / hide the given column */
+ toggleColumnVisibility,
+ };
+}
+
+function computeInitialColumns(
+ columns: DataTableColumn[],
+): DataTableColumn[] {
+ return columns.map((c) => ({
+ ...c,
+ visible: c.visible !== false,
+ }));
+}
diff --git a/desktop/app/src/utils/LowPassFilter.tsx b/desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx
similarity index 88%
rename from desktop/app/src/utils/LowPassFilter.tsx
rename to desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx
index 7c5047c6f..72c06fcc5 100644
--- a/desktop/app/src/utils/LowPassFilter.tsx
+++ b/desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx
@@ -7,8 +7,6 @@
* @format
*/
-import invariant from 'invariant';
-
export default class LowPassFilter {
constructor(smoothing: number = 0.9) {
this.smoothing = smoothing;
@@ -29,10 +27,10 @@ export default class LowPassFilter {
if (this.hasFullBuffer()) {
const tmp: number | undefined = this.buffer.shift();
- invariant(
- tmp !== undefined,
- 'Invariant violation: Buffer reported full but shift returned nothing.',
- );
+ if (tmp === undefined)
+ throw new Error(
+ 'Invariant violation: Buffer reported full but shift returned nothing.',
+ );
removed = tmp;
}
diff --git a/desktop/flipper-plugin/src/ui/utils/Rect.tsx b/desktop/flipper-plugin/src/ui/utils/Rect.tsx
new file mode 100644
index 000000000..7df2160f5
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/utils/Rect.tsx
@@ -0,0 +1,15 @@
+/**
+ * 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
+ */
+
+export type Rect = {
+ top: number;
+ left: number;
+ height: number;
+ width: number;
+};
diff --git a/desktop/app/src/utils/snap.tsx b/desktop/flipper-plugin/src/ui/utils/snap.tsx
similarity index 99%
rename from desktop/app/src/utils/snap.tsx
rename to desktop/flipper-plugin/src/ui/utils/snap.tsx
index 3ba886e26..d195b842b 100644
--- a/desktop/app/src/utils/snap.tsx
+++ b/desktop/flipper-plugin/src/ui/utils/snap.tsx
@@ -7,7 +7,7 @@
* @format
*/
-import {Rect} from './geometry';
+import {Rect} from './Rect';
export const SNAP_SIZE = 16;
diff --git a/desktop/flipper-plugin/src/ui/utils/widthUtils.tsx b/desktop/flipper-plugin/src/ui/utils/widthUtils.tsx
new file mode 100644
index 000000000..02904133f
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/utils/widthUtils.tsx
@@ -0,0 +1,23 @@
+/**
+ * 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
+ */
+
+export type Percentage = string; // currently broken, see https://github.com/microsoft/TypeScript/issues/41651. Should be `${number}%`;
+
+export type Width = undefined | number | Percentage; // undefined represents auto flex
+
+export function isPercentage(width: any): width is Percentage {
+ return typeof width === 'string' && width[width.length - 1] === '%';
+}
+
+export function calculatePercentage(
+ parentWidth: number,
+ selfWidth: number,
+): Percentage {
+ return `${(100 / parentWidth) * selfWidth}%` as const;
+}