Introduced sorting, column visibility and column resizing
Summary: Add support for resizable columns, column sorting, and hiding / showing columns Moved some utilities from Flipper to flipper-plugin, such as Interactive and LowPassFilter Split DataTable into two components; DataSourceRenderer which takes care of purely rendering the virtualization, and DataTable that has the Chrome around that, such as column headers, search bar, etc. Reviewed By: nikoant Differential Revision: D26321105 fbshipit-source-id: 32b8fc03b4fb97b3af52b23e273c3e5b8cbc4498
This commit is contained in:
committed by
Facebook GitHub Bot
parent
86ad413669
commit
44bb5b1beb
@@ -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)
|
||||
|
||||
713
desktop/flipper-plugin/src/ui/Interactive.tsx
Normal file
713
desktop/flipper-plugin/src/ui/Interactive.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* 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 styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
import LowPassFilter from './utils/LowPassFilter';
|
||||
import {
|
||||
getDistanceTo,
|
||||
maybeSnapLeft,
|
||||
maybeSnapTop,
|
||||
SNAP_SIZE,
|
||||
} from './utils/snap';
|
||||
import type {Rect} from './utils/Rect';
|
||||
|
||||
const WINDOW_CURSOR_BOUNDARY = 5;
|
||||
|
||||
type CursorState = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
type ResizingSides =
|
||||
| {
|
||||
left?: boolean;
|
||||
top?: boolean;
|
||||
bottom?: boolean;
|
||||
right?: boolean;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const ALL_RESIZABLE: ResizingSides = {
|
||||
bottom: true,
|
||||
left: true,
|
||||
right: true,
|
||||
top: true,
|
||||
};
|
||||
|
||||
export type InteractiveProps = {
|
||||
isMovableAnchor?: (event: React.MouseEvent) => boolean;
|
||||
onMoveStart?: () => void;
|
||||
onMoveEnd?: () => void;
|
||||
onMove?: (top: number, left: number, event: MouseEvent) => void;
|
||||
id?: string;
|
||||
movable?: boolean;
|
||||
hidden?: boolean;
|
||||
moving?: boolean;
|
||||
grow?: boolean;
|
||||
siblings?: Partial<{[key: string]: Rect}>;
|
||||
updateCursor?: (cursor?: string | null | undefined) => void;
|
||||
zIndex?: number;
|
||||
top?: number;
|
||||
left?: number;
|
||||
minTop?: number;
|
||||
minLeft?: number;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
onCanResize?: (sides: ResizingSides) => void;
|
||||
onResizeStart?: () => void;
|
||||
onResizeEnd?: () => void;
|
||||
onResize?: (width: number, height: number) => void;
|
||||
resizing?: boolean;
|
||||
resizable?: boolean | ResizingSides;
|
||||
innerRef?: (elem: HTMLElement | null) => void;
|
||||
style?: Object;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
gutterWidth?: number;
|
||||
};
|
||||
|
||||
type InteractiveState = {
|
||||
moving: boolean;
|
||||
movingInitialProps: InteractiveProps | null | undefined;
|
||||
movingInitialCursor: CursorState | null | undefined;
|
||||
cursor: string | undefined;
|
||||
resizingSides: ResizingSides;
|
||||
couldResize: boolean;
|
||||
resizing: boolean;
|
||||
resizingInitialRect: Rect | null | undefined;
|
||||
resizingInitialCursor: CursorState | null | undefined;
|
||||
};
|
||||
|
||||
const InteractiveContainer = styled.div({
|
||||
willChange: 'transform, height, width, z-index',
|
||||
});
|
||||
InteractiveContainer.displayName = 'Interactive:InteractiveContainer';
|
||||
|
||||
export class Interactive extends React.Component<
|
||||
InteractiveProps,
|
||||
InteractiveState
|
||||
> {
|
||||
constructor(props: InteractiveProps, context: Object) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
couldResize: false,
|
||||
cursor: undefined,
|
||||
moving: false,
|
||||
movingInitialCursor: null,
|
||||
movingInitialProps: null,
|
||||
resizing: false,
|
||||
resizingInitialCursor: null,
|
||||
resizingInitialRect: null,
|
||||
resizingSides: null,
|
||||
};
|
||||
|
||||
this.globalMouse = false;
|
||||
}
|
||||
|
||||
globalMouse: boolean;
|
||||
ref?: HTMLElement | null;
|
||||
|
||||
nextTop?: number | null;
|
||||
nextLeft?: number | null;
|
||||
nextEvent?: MouseEvent | null;
|
||||
|
||||
static defaultProps = {
|
||||
minHeight: 0,
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
onMouseMove = (event: MouseEvent) => {
|
||||
if (this.state.moving) {
|
||||
this.calculateMove(event);
|
||||
} else if (this.state.resizing) {
|
||||
this.calculateResize(event);
|
||||
} else {
|
||||
this.calculateResizable(event);
|
||||
}
|
||||
};
|
||||
|
||||
startAction = (event: React.MouseEvent) => {
|
||||
this.globalMouse = true;
|
||||
window.addEventListener('pointerup', this.endAction, {passive: true});
|
||||
window.addEventListener('pointermove', this.onMouseMove, {passive: true});
|
||||
|
||||
const {isMovableAnchor} = this.props;
|
||||
if (isMovableAnchor && isMovableAnchor(event)) {
|
||||
this.startTitleAction(event);
|
||||
} else {
|
||||
this.startWindowAction(event);
|
||||
}
|
||||
};
|
||||
|
||||
startTitleAction(event: React.MouseEvent) {
|
||||
if (this.state.couldResize) {
|
||||
this.startResizeAction(event);
|
||||
} else if (this.props.movable === true) {
|
||||
this.startMoving(event);
|
||||
}
|
||||
}
|
||||
|
||||
startMoving(event: React.MouseEvent) {
|
||||
const {onMoveStart} = this.props;
|
||||
if (onMoveStart) {
|
||||
onMoveStart();
|
||||
}
|
||||
|
||||
if (this.context.os) {
|
||||
// pause OS timers to avoid lag when dragging
|
||||
this.context.os.timers.pause();
|
||||
}
|
||||
|
||||
const topLpf = new LowPassFilter();
|
||||
const leftLpf = new LowPassFilter();
|
||||
|
||||
this.nextTop = null;
|
||||
this.nextLeft = null;
|
||||
this.nextEvent = null;
|
||||
|
||||
const onFrame = () => {
|
||||
if (!this.state.moving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {nextEvent, nextTop, nextLeft} = this;
|
||||
if (nextEvent && nextTop != null && nextLeft != null) {
|
||||
if (topLpf.hasFullBuffer()) {
|
||||
const newTop = topLpf.next(nextTop);
|
||||
const newLeft = leftLpf.next(nextLeft);
|
||||
this.move(newTop, newLeft, nextEvent);
|
||||
} else {
|
||||
this.move(nextTop, nextLeft, nextEvent);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(onFrame);
|
||||
};
|
||||
|
||||
this.setState(
|
||||
{
|
||||
cursor: 'move',
|
||||
moving: true,
|
||||
movingInitialCursor: {
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
},
|
||||
movingInitialProps: this.props,
|
||||
},
|
||||
onFrame,
|
||||
);
|
||||
}
|
||||
|
||||
getPossibleTargetWindows(rect: Rect) {
|
||||
const closeWindows = [];
|
||||
|
||||
const {siblings} = this.props;
|
||||
if (siblings) {
|
||||
for (const key in siblings) {
|
||||
if (key === this.props.id) {
|
||||
// don't target ourselves
|
||||
continue;
|
||||
}
|
||||
|
||||
const win = siblings[key];
|
||||
if (win) {
|
||||
const distance = getDistanceTo(rect, win);
|
||||
if (distance <= SNAP_SIZE) {
|
||||
closeWindows.push(win);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closeWindows;
|
||||
}
|
||||
|
||||
startWindowAction(event: React.MouseEvent) {
|
||||
if (this.state.couldResize) {
|
||||
this.startResizeAction(event);
|
||||
}
|
||||
}
|
||||
|
||||
startResizeAction(event: React.MouseEvent) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const {onResizeStart} = this.props;
|
||||
if (onResizeStart) {
|
||||
onResizeStart();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
resizing: true,
|
||||
resizingInitialCursor: {
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
},
|
||||
resizingInitialRect: this.getRect(),
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
_prevProps: InteractiveProps,
|
||||
prevState: InteractiveState,
|
||||
) {
|
||||
if (prevState.cursor !== this.state.cursor) {
|
||||
const {updateCursor} = this.props;
|
||||
if (updateCursor) {
|
||||
updateCursor(this.state.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetMoving() {
|
||||
const {onMoveEnd} = this.props;
|
||||
if (onMoveEnd) {
|
||||
onMoveEnd();
|
||||
}
|
||||
|
||||
if (this.context.os) {
|
||||
// resume os timers
|
||||
this.context.os.timers.resume();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
cursor: undefined,
|
||||
moving: false,
|
||||
movingInitialProps: undefined,
|
||||
resizingInitialCursor: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
resetResizing() {
|
||||
const {onResizeEnd} = this.props;
|
||||
if (onResizeEnd) {
|
||||
onResizeEnd();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
resizing: false,
|
||||
resizingInitialCursor: undefined,
|
||||
resizingInitialRect: undefined,
|
||||
resizingSides: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.endAction();
|
||||
}
|
||||
|
||||
endAction = () => {
|
||||
this.globalMouse = false;
|
||||
|
||||
window.removeEventListener('pointermove', this.onMouseMove);
|
||||
window.removeEventListener('pointerup', this.endAction);
|
||||
|
||||
if (this.state.moving) {
|
||||
this.resetMoving();
|
||||
}
|
||||
|
||||
if (this.state.resizing) {
|
||||
this.resetResizing();
|
||||
}
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
if (!this.state.resizing && !this.state.moving) {
|
||||
this.setState({
|
||||
cursor: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onClick = (e: React.MouseEvent) => {
|
||||
if (this.state.couldResize) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
calculateMove(event: MouseEvent) {
|
||||
const {movingInitialCursor, movingInitialProps} = this.state;
|
||||
|
||||
const {clientX: cursorLeft, clientY: cursorTop} = event;
|
||||
|
||||
const movedLeft = movingInitialCursor!.left - cursorLeft;
|
||||
const movedTop = movingInitialCursor!.top - cursorTop;
|
||||
|
||||
let newLeft = (movingInitialProps!.left || 0) - movedLeft;
|
||||
let newTop = (movingInitialProps!.top || 0) - movedTop;
|
||||
|
||||
if (event.altKey) {
|
||||
const snapProps = this.getRect();
|
||||
const windows = this.getPossibleTargetWindows(snapProps);
|
||||
newLeft = maybeSnapLeft(snapProps, windows, newLeft);
|
||||
newTop = maybeSnapTop(snapProps, windows, newTop);
|
||||
}
|
||||
|
||||
this.nextTop = newTop;
|
||||
this.nextLeft = newLeft;
|
||||
this.nextEvent = event;
|
||||
}
|
||||
|
||||
resize(width: number, height: number) {
|
||||
if (width === this.props.width && height === this.props.height) {
|
||||
// noop
|
||||
return;
|
||||
}
|
||||
|
||||
const {onResize} = this.props;
|
||||
if (!onResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
width = Math.max(this.props.minWidth || 0, width);
|
||||
height = Math.max(this.props.minHeight || 0, height);
|
||||
|
||||
const {maxHeight, maxWidth} = this.props;
|
||||
if (maxWidth != null) {
|
||||
width = Math.min(maxWidth, width);
|
||||
}
|
||||
if (maxHeight != null) {
|
||||
height = Math.min(maxHeight, height);
|
||||
}
|
||||
|
||||
onResize(width, height);
|
||||
}
|
||||
|
||||
move(top: number, left: number, event: MouseEvent) {
|
||||
top = Math.max(this.props.minTop || 0, top);
|
||||
left = Math.max(this.props.minLeft || 0, left);
|
||||
|
||||
if (top === this.props.top && left === this.props.left) {
|
||||
// noop
|
||||
return;
|
||||
}
|
||||
|
||||
const {onMove} = this.props;
|
||||
if (onMove) {
|
||||
onMove(top, left, event);
|
||||
}
|
||||
}
|
||||
|
||||
calculateResize(event: MouseEvent) {
|
||||
const {
|
||||
resizingInitialCursor,
|
||||
resizingInitialRect,
|
||||
resizingSides,
|
||||
} = this.state;
|
||||
|
||||
const deltaLeft = resizingInitialCursor!.left - event.clientX;
|
||||
const deltaTop = resizingInitialCursor!.top - event.clientY;
|
||||
|
||||
let newLeft = resizingInitialRect!.left;
|
||||
let newTop = resizingInitialRect!.top;
|
||||
|
||||
let newWidth = resizingInitialRect!.width;
|
||||
let newHeight = resizingInitialRect!.height;
|
||||
|
||||
// right
|
||||
if (resizingSides!.right === true) {
|
||||
newWidth -= deltaLeft;
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (resizingSides!.bottom === true) {
|
||||
newHeight -= deltaTop;
|
||||
}
|
||||
|
||||
const rect = this.getRect();
|
||||
|
||||
// left
|
||||
if (resizingSides!.left === true) {
|
||||
newLeft -= deltaLeft;
|
||||
newWidth += deltaLeft;
|
||||
|
||||
if (this.props.movable === true) {
|
||||
// prevent from being shrunk past the minimum width
|
||||
const right = rect.left + rect.width;
|
||||
const maxLeft = right - (this.props.minWidth || 0);
|
||||
|
||||
let cleanLeft = Math.max(0, newLeft);
|
||||
cleanLeft = Math.min(cleanLeft, maxLeft);
|
||||
newWidth -= Math.abs(newLeft - cleanLeft);
|
||||
newLeft = cleanLeft;
|
||||
}
|
||||
}
|
||||
|
||||
// top
|
||||
if (resizingSides!.top === true) {
|
||||
newTop -= deltaTop;
|
||||
newHeight += deltaTop;
|
||||
|
||||
if (this.props.movable === true) {
|
||||
// prevent from being shrunk past the minimum height
|
||||
const bottom = rect.top + rect.height;
|
||||
const maxTop = bottom - (this.props.minHeight || 0);
|
||||
|
||||
let cleanTop = Math.max(0, newTop);
|
||||
cleanTop = Math.min(cleanTop, maxTop);
|
||||
newHeight += newTop - cleanTop;
|
||||
newTop = cleanTop;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
const windows = this.getPossibleTargetWindows(rect);
|
||||
|
||||
if (resizingSides!.left === true) {
|
||||
const newLeft2 = maybeSnapLeft(rect, windows, newLeft);
|
||||
newWidth += newLeft - newLeft2;
|
||||
newLeft = newLeft2;
|
||||
}
|
||||
|
||||
if (resizingSides!.top === true) {
|
||||
const newTop2 = maybeSnapTop(rect, windows, newTop);
|
||||
newHeight += newTop - newTop2;
|
||||
newTop = newTop2;
|
||||
}
|
||||
|
||||
if (resizingSides!.bottom === true) {
|
||||
newHeight = maybeSnapTop(rect, windows, newTop + newHeight) - newTop;
|
||||
}
|
||||
|
||||
if (resizingSides!.right === true) {
|
||||
newWidth = maybeSnapLeft(rect, windows, newLeft + newWidth) - newLeft;
|
||||
}
|
||||
}
|
||||
|
||||
this.move(newTop, newLeft, event);
|
||||
this.resize(newWidth, newHeight);
|
||||
}
|
||||
|
||||
getRect(): Rect {
|
||||
const {props, ref} = this;
|
||||
if (!ref) throw new Error('expected ref');
|
||||
|
||||
return {
|
||||
height: ref!.offsetHeight || 0,
|
||||
left: props.left || 0,
|
||||
top: props.top || 0,
|
||||
width: ref!.offsetWidth || 0,
|
||||
};
|
||||
}
|
||||
|
||||
getResizable(): ResizingSides {
|
||||
const {resizable} = this.props;
|
||||
|
||||
if (resizable === true) {
|
||||
return ALL_RESIZABLE;
|
||||
} else if (resizable == null || resizable === false) {
|
||||
return;
|
||||
} else {
|
||||
return resizable;
|
||||
}
|
||||
}
|
||||
|
||||
checkIfResizable(
|
||||
event: MouseEvent,
|
||||
):
|
||||
| {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
top: boolean;
|
||||
bottom: boolean;
|
||||
}
|
||||
| undefined {
|
||||
const canResize = this.getResizable();
|
||||
if (!canResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {left: offsetLeft, top: offsetTop} = this.ref.getBoundingClientRect();
|
||||
const {height, width} = this.getRect();
|
||||
|
||||
const x = event.clientX - offsetLeft;
|
||||
const y = event.clientY - offsetTop;
|
||||
|
||||
const gutterWidth = this.props.gutterWidth || WINDOW_CURSOR_BOUNDARY;
|
||||
const atTop: boolean = y <= gutterWidth;
|
||||
const atBottom: boolean = y >= height - gutterWidth;
|
||||
|
||||
const atLeft: boolean = x <= gutterWidth;
|
||||
const atRight: boolean = x >= width - gutterWidth;
|
||||
|
||||
return {
|
||||
bottom: canResize.bottom === true && atBottom,
|
||||
left: canResize.left === true && atLeft,
|
||||
right: canResize.right === true && atRight,
|
||||
top: canResize.top === true && atTop,
|
||||
};
|
||||
}
|
||||
|
||||
calculateResizable(event: MouseEvent) {
|
||||
const resizing = this.checkIfResizable(event);
|
||||
if (!resizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canResize = this.getResizable();
|
||||
if (!canResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {bottom, left, right, top} = resizing;
|
||||
let newCursor;
|
||||
|
||||
const movingHorizontal = left || right;
|
||||
const movingVertical = top || left;
|
||||
|
||||
// left
|
||||
if (left) {
|
||||
newCursor = 'ew-resize';
|
||||
}
|
||||
|
||||
// right
|
||||
if (right) {
|
||||
newCursor = 'ew-resize';
|
||||
}
|
||||
|
||||
// if resizing vertically and one side can't be resized then use different cursor
|
||||
if (
|
||||
movingHorizontal &&
|
||||
(canResize.left !== true || canResize.right !== true)
|
||||
) {
|
||||
newCursor = 'col-resize';
|
||||
}
|
||||
|
||||
// top
|
||||
if (top) {
|
||||
newCursor = 'ns-resize';
|
||||
|
||||
// top left
|
||||
if (left) {
|
||||
newCursor = 'nwse-resize';
|
||||
}
|
||||
|
||||
// top right
|
||||
if (right) {
|
||||
newCursor = 'nesw-resize';
|
||||
}
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (bottom) {
|
||||
newCursor = 'ns-resize';
|
||||
|
||||
// bottom left
|
||||
if (left) {
|
||||
newCursor = 'nesw-resize';
|
||||
}
|
||||
|
||||
// bottom right
|
||||
if (right) {
|
||||
newCursor = 'nwse-resize';
|
||||
}
|
||||
}
|
||||
|
||||
// if resizing horziontally and one side can't be resized then use different cursor
|
||||
if (
|
||||
movingVertical &&
|
||||
!movingHorizontal &&
|
||||
(canResize.top !== true || canResize.bottom !== true)
|
||||
) {
|
||||
newCursor = 'row-resize';
|
||||
}
|
||||
|
||||
const resizingSides = {
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
};
|
||||
|
||||
const {onCanResize} = this.props;
|
||||
if (onCanResize) {
|
||||
onCanResize({});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
couldResize: Boolean(newCursor),
|
||||
cursor: newCursor,
|
||||
resizingSides,
|
||||
});
|
||||
}
|
||||
|
||||
setRef = (ref: HTMLElement | null) => {
|
||||
this.ref = ref;
|
||||
|
||||
const {innerRef} = this.props;
|
||||
if (innerRef) {
|
||||
innerRef(ref);
|
||||
}
|
||||
};
|
||||
|
||||
onLocalMouseMove = (event: React.MouseEvent) => {
|
||||
if (!this.globalMouse) {
|
||||
this.onMouseMove(event.nativeEvent);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {grow, height, left, movable, top, width, zIndex} = this.props;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
cursor: this.state.cursor,
|
||||
zIndex: zIndex == null ? 'auto' : zIndex,
|
||||
};
|
||||
|
||||
if (movable === true || top != null || left != null) {
|
||||
if (grow === true) {
|
||||
style.left = left || 0;
|
||||
style.top = top || 0;
|
||||
} else {
|
||||
style.transform = `translate3d(${left || 0}px, ${top || 0}px, 0)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (grow === true) {
|
||||
style.right = 0;
|
||||
style.bottom = 0;
|
||||
style.width = '100%';
|
||||
style.height = '100%';
|
||||
} else {
|
||||
style.width = width == null ? 'auto' : width;
|
||||
style.height = height == null ? 'auto' : height;
|
||||
}
|
||||
|
||||
if (this.props.style) {
|
||||
Object.assign(style, this.props.style);
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractiveContainer
|
||||
className={this.props.className}
|
||||
hidden={this.props.hidden}
|
||||
ref={this.setRef}
|
||||
onMouseDown={this.startAction}
|
||||
onMouseMove={this.onLocalMouseMove}
|
||||
onMouseLeave={this.onMouseLeave} // eslint-disable-next-line
|
||||
onClick={this.onClick}
|
||||
style={style}>
|
||||
{this.props.children}
|
||||
</InteractiveContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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 LowPassFilter from '../utils/LowPassFilter';
|
||||
|
||||
test('hasFullBuffer', () => {
|
||||
const lpf = new LowPassFilter();
|
||||
expect(lpf.hasFullBuffer()).toBeFalsy();
|
||||
|
||||
lpf.push(1);
|
||||
lpf.push(2);
|
||||
lpf.push(3);
|
||||
lpf.push(4);
|
||||
lpf.push(5);
|
||||
|
||||
expect(lpf.hasFullBuffer()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('push on full buffer returns shifted value', () => {
|
||||
const lpf = new LowPassFilter();
|
||||
expect(lpf.push(1)).toBe(0);
|
||||
expect(lpf.push(2)).toBe(0);
|
||||
expect(lpf.push(3)).toBe(0);
|
||||
expect(lpf.push(4)).toBe(0);
|
||||
expect(lpf.push(5)).toBe(0);
|
||||
expect(lpf.push(6)).toBe(1);
|
||||
expect(lpf.push(7)).toBe(2);
|
||||
});
|
||||
|
||||
test('next returns smoothed value', () => {
|
||||
const lpf = new LowPassFilter();
|
||||
expect(lpf.next(1)).toBe(0.9);
|
||||
expect(lpf.next(2)).toBe(1.881);
|
||||
});
|
||||
|
||||
test('next returns smoothed value with custom smoothing', () => {
|
||||
const lpf = new LowPassFilter(0.5);
|
||||
expect(lpf.next(1)).toBe(0.5);
|
||||
expect(lpf.next(2)).toBe(1.125);
|
||||
});
|
||||
261
desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx
Normal file
261
desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx
Normal file
@@ -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<T extends object, C> = {
|
||||
/**
|
||||
* The data source to render
|
||||
*/
|
||||
dataSource: DataSource<T, any, any>;
|
||||
/**
|
||||
* 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: <T extends object, C>(
|
||||
props: DataSourceProps<T, C>,
|
||||
) => React.ReactElement = memo(function DataSourceRenderer({
|
||||
dataSource,
|
||||
defaultRowHeight,
|
||||
useFixedRowHeight,
|
||||
context,
|
||||
itemRenderer,
|
||||
autoScroll,
|
||||
_testHeight,
|
||||
}: DataSourceProps<any, any>) {
|
||||
/**
|
||||
* 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 | HTMLDivElement>(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 (
|
||||
<TableContainer onScroll={onScroll} ref={parentRef}>
|
||||
<TableWindow height={virtualizer.totalSize}>
|
||||
{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*/}
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: useFixedRowHeight ? virtualRow.size : undefined,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
ref={useFixedRowHeight ? undefined : virtualRow.measureRef}>
|
||||
{itemRenderer(
|
||||
dataSource.getItem(virtualRow.index),
|
||||
virtualRow.index,
|
||||
context,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TableWindow>
|
||||
</TableContainer>
|
||||
);
|
||||
}) 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%',
|
||||
}));
|
||||
@@ -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<T extends object> {
|
||||
interface DataTableProps<T = any> {
|
||||
columns: DataTableColumn<T>[];
|
||||
dataSource: DataSource<T, any, any>;
|
||||
zebra?: boolean;
|
||||
autoScroll?: boolean;
|
||||
tableManagerRef?: RefObject<TableManager>;
|
||||
_testHeight?: number; // exposed for unit testing only
|
||||
}
|
||||
|
||||
export type DataTableColumn<T> = (
|
||||
| {
|
||||
// existing data
|
||||
key: keyof T;
|
||||
}
|
||||
| {
|
||||
// derived data / custom rendering
|
||||
key: string;
|
||||
onRender?: (row: T) => React.ReactNode;
|
||||
}
|
||||
) & {
|
||||
label?: string;
|
||||
width?: number | '*';
|
||||
export type DataTableColumn<T = any> = {
|
||||
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<T extends object> {
|
||||
export interface RenderingConfig<T = any> {
|
||||
columns: DataTableColumn<T>[];
|
||||
zebra: boolean;
|
||||
onMouseDown: (e: React.MouseEvent, row: T) => void;
|
||||
onMouseEnter: (e: React.MouseEvent, row: T) => void;
|
||||
}
|
||||
|
||||
enum UpdatePrio {
|
||||
NONE,
|
||||
LOW,
|
||||
HIGH,
|
||||
}
|
||||
|
||||
export const DataTable: <T extends object>(
|
||||
props: DataTableProps<T>,
|
||||
) => React.ReactElement = memo(function DataSourceRenderer(
|
||||
props: DataTableProps<any>,
|
||||
) {
|
||||
const {dataSource} = props;
|
||||
export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||
const tableManager = useDataTableManager<T>(props.dataSource, props.columns);
|
||||
if (props.tableManagerRef) {
|
||||
(props.tableManagerRef as MutableRefObject<TableManager>).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 | HTMLDivElement>(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 (
|
||||
<TableContainer onScroll={onScroll} ref={parentRef}>
|
||||
<TableWindow height={virtualizer.totalSize}>
|
||||
{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*/}
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
className={virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
backgroundColor:
|
||||
renderingConfig.zebra && virtualRow.index % 2
|
||||
? theme.backgroundWash
|
||||
: theme.backgroundDefault,
|
||||
height: usesWrapping ? undefined : virtualRow.size,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
ref={usesWrapping ? virtualRow.measureRef : undefined}>
|
||||
{
|
||||
<TableRow
|
||||
key={virtualRow.index}
|
||||
config={renderingConfig}
|
||||
row={dataSource.getItem(virtualRow.index)}
|
||||
highlighted={false}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</TableWindow>
|
||||
</TableContainer>
|
||||
<Layout.Top>
|
||||
<TableHead
|
||||
columns={tableManager.columns}
|
||||
visibleColumns={tableManager.visibleColumns}
|
||||
onColumnResize={tableManager.resizeColumn}
|
||||
onReset={tableManager.reset}
|
||||
onColumnToggleVisibility={tableManager.toggleColumnVisibility}
|
||||
sorting={tableManager.sorting}
|
||||
onColumnSort={tableManager.sortColumn}
|
||||
/>
|
||||
<DataSourceRenderer<any, RenderContext>
|
||||
dataSource={props.dataSource}
|
||||
autoScroll={props.autoScroll}
|
||||
useFixedRowHeight={!usesWrapping}
|
||||
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
||||
context={renderingConfig}
|
||||
itemRenderer={itemRenderer}
|
||||
_testHeight={props._testHeight}
|
||||
/>
|
||||
</Layout.Top>
|
||||
);
|
||||
}) as any;
|
||||
}
|
||||
|
||||
const TableContainer = styled.div({
|
||||
overflowY: 'scroll',
|
||||
overflowX: 'hidden',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
});
|
||||
export type RenderContext = {
|
||||
columns: DataTableColumn<any>[];
|
||||
};
|
||||
|
||||
const TableWindow = styled.div<{height: number}>(({height}) => ({
|
||||
height,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}));
|
||||
function itemRenderer(item: any, index: number, renderContext: RenderContext) {
|
||||
return (
|
||||
<TableRow
|
||||
key={index}
|
||||
config={renderContext}
|
||||
row={item}
|
||||
highlighted={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
254
desktop/flipper-plugin/src/ui/datatable/TableHead.tsx
Normal file
254
desktop/flipper-plugin/src/ui/datatable/TableHead.tsx
Normal file
@@ -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 (
|
||||
<SortIconsContainer direction={direction}>
|
||||
<CaretUpFilled
|
||||
className={
|
||||
'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '')
|
||||
}
|
||||
/>
|
||||
<CaretDownFilled
|
||||
className={
|
||||
'ant-table-column-sorter-down ' +
|
||||
(direction === 'down' ? 'active' : '')
|
||||
}
|
||||
/>
|
||||
</SortIconsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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)<InteractiveProps>({
|
||||
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<HTMLDivElement | null>(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 = (
|
||||
<TableHeaderColumnContainer center>
|
||||
<Text strong>{title}</Text>
|
||||
{isSortable && <SortIcons direction={sorted} />}
|
||||
</TableHeaderColumnContainer>
|
||||
);
|
||||
|
||||
if (isResizable) {
|
||||
children = (
|
||||
<TableHeaderColumnInteractive
|
||||
grow={true}
|
||||
resizable={RIGHT_RESIZABLE}
|
||||
onResize={onResize}
|
||||
minWidth={20}>
|
||||
{children}
|
||||
</TableHeaderColumnInteractive>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHeadColumnContainer
|
||||
width={width}
|
||||
title={title}
|
||||
onClick={sortable ? () => onSort(id) : undefined}
|
||||
ref={ref}>
|
||||
{children}
|
||||
</TableHeadColumnContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({
|
||||
columns,
|
||||
visibleColumns,
|
||||
...props
|
||||
}: {
|
||||
columns: DataTableColumn<any>[];
|
||||
visibleColumns: DataTableColumn<any>[];
|
||||
onColumnResize: OnColumnResize;
|
||||
onColumnToggleVisibility: (key: string) => void;
|
||||
onReset: () => void;
|
||||
sorting: Sorting | undefined;
|
||||
onColumnSort: (key: string) => void;
|
||||
}) {
|
||||
const menu = (
|
||||
<Menu style={{minWidth: 200}}>
|
||||
{columns.map((column) => (
|
||||
<Menu.Item
|
||||
key={column.key}
|
||||
onClick={(e) => {
|
||||
e.domEvent.stopPropagation();
|
||||
e.domEvent.preventDefault();
|
||||
props.onColumnToggleVisibility(column.key);
|
||||
}}>
|
||||
<Checkbox checked={column.visible}>
|
||||
{column.title || column.key}
|
||||
</Checkbox>
|
||||
</Menu.Item>
|
||||
))}
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="reset" onClick={props.onReset}>
|
||||
Reset
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableHeadContainer>
|
||||
{visibleColumns.map((column, i) => (
|
||||
<TableHeadColumn
|
||||
key={column.key}
|
||||
id={column.key}
|
||||
isResizable={i < visibleColumns.length - 1}
|
||||
width={column.width}
|
||||
isSortable={true} // might depend in the future on for example .getValue()
|
||||
sortOrder={props.sorting}
|
||||
onSort={props.onColumnSort}
|
||||
onColumnResize={props.onColumnResize}
|
||||
sorted={
|
||||
props.sorting?.key === column.key
|
||||
? props.sorting!.direction
|
||||
: undefined
|
||||
}
|
||||
title={column.title}
|
||||
/>
|
||||
))}
|
||||
<Dropdown overlay={menu} trigger={['click']}>
|
||||
<SettingsButton type="text">
|
||||
<DownOutlined />
|
||||
</SettingsButton>
|
||||
</Dropdown>
|
||||
</TableHeadContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const SettingsButton = styled(Button)({
|
||||
padding: 4,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
backgroundColor: theme.backgroundWash,
|
||||
});
|
||||
@@ -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<TableBodyRowContainerProps>(
|
||||
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<TableBodyRowContainerProps>(
|
||||
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<any>;
|
||||
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 (
|
||||
<TableBodyRowContainer highlighted={highlighted} data-key={row.key}>
|
||||
{config.columns.map((col) => {
|
||||
const value = (col as any).onRender
|
||||
? (col as any).onRender(row)
|
||||
: (row as any)[col.key] ?? '';
|
||||
<TableBodyRowContainer
|
||||
highlighted={highlighted}
|
||||
data-key={row.key}
|
||||
className="ant-table-row">
|
||||
{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 (
|
||||
<TableBodyColumnContainer
|
||||
key={col.key as string}
|
||||
multiline={col.wrap}
|
||||
justifyContent={col.align ? col.align : 'flex-start'}
|
||||
width={normaliseColumnWidth(col.width)}>
|
||||
{value}
|
||||
</TableBodyColumnContainer>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<TableBodyColumnContainer
|
||||
className="ant-table-cell"
|
||||
key={col.key as string}
|
||||
multiline={col.wrap}
|
||||
justifyContent={col.align ? col.align : 'flex-start'}
|
||||
width={col.width}>
|
||||
{value}
|
||||
</TableBodyColumnContainer>
|
||||
);
|
||||
})}
|
||||
</TableBodyRowContainer>
|
||||
);
|
||||
});
|
||||
|
||||
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] === '%';
|
||||
}
|
||||
|
||||
@@ -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<Todo>([
|
||||
{
|
||||
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<TableManager>();
|
||||
const rendering = render(
|
||||
<DataTable dataSource={ds} columns={columns} tableManagerRef={ref} />,
|
||||
);
|
||||
{
|
||||
const elem = await rendering.findAllByText('test DataTable');
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
test DataTable
|
||||
</div>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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<TableManager>();
|
||||
const rendering = render(
|
||||
<DataTable dataSource={ds} columns={columns} tableManagerRef={ref} />,
|
||||
);
|
||||
{
|
||||
const elem = await rendering.findAllByText('test DataTable');
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
test DataTable
|
||||
</div>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// hide done
|
||||
act(() => {
|
||||
ref.current?.toggleColumnVisibility('done');
|
||||
});
|
||||
{
|
||||
const elem = await rendering.findAllByText('test DataTable');
|
||||
expect(elem.length).toBe(1);
|
||||
expect(elem[0].parentElement).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
|
||||
>
|
||||
<div
|
||||
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
|
||||
>
|
||||
test DataTable
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// 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<TableManager>();
|
||||
const rendering = render(
|
||||
<DataTable
|
||||
dataSource={ds}
|
||||
columns={columns}
|
||||
tableManagerRef={ref}
|
||||
_testHeight={400}
|
||||
/>,
|
||||
);
|
||||
// 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',
|
||||
]);
|
||||
}
|
||||
});
|
||||
116
desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx
Normal file
116
desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx
Normal file
@@ -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<typeof useDataTableManager>;
|
||||
|
||||
/**
|
||||
* A hook that coordinates filtering, sorting etc for a DataSource
|
||||
*/
|
||||
export function useDataTableManager<T extends object>(
|
||||
dataSource: DataSource<T>,
|
||||
defaultColumns: DataTableColumn<T>[],
|
||||
) {
|
||||
// TODO: restore from local storage
|
||||
const [columns, setEffectiveColumns] = useState(
|
||||
computeInitialColumns(defaultColumns),
|
||||
);
|
||||
const [sorting, setSorting] = useState<Sorting | undefined>(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<any>[]) => {
|
||||
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<any>[]) => {
|
||||
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<any>[],
|
||||
): DataTableColumn<any>[] {
|
||||
return columns.map((c) => ({
|
||||
...c,
|
||||
visible: c.visible !== false,
|
||||
}));
|
||||
}
|
||||
58
desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx
Normal file
58
desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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 default class LowPassFilter {
|
||||
constructor(smoothing: number = 0.9) {
|
||||
this.smoothing = smoothing;
|
||||
this.buffer = [];
|
||||
this.bufferMaxSize = 5;
|
||||
}
|
||||
|
||||
bufferMaxSize: number;
|
||||
smoothing: number;
|
||||
buffer: Array<number>;
|
||||
|
||||
hasFullBuffer(): boolean {
|
||||
return this.buffer.length === this.bufferMaxSize;
|
||||
}
|
||||
|
||||
push(value: number): number {
|
||||
let removed: number = 0;
|
||||
|
||||
if (this.hasFullBuffer()) {
|
||||
const tmp: number | undefined = this.buffer.shift();
|
||||
if (tmp === undefined)
|
||||
throw new Error(
|
||||
'Invariant violation: Buffer reported full but shift returned nothing.',
|
||||
);
|
||||
removed = tmp;
|
||||
}
|
||||
|
||||
this.buffer.push(value);
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
next(nextValue: number): number {
|
||||
// push new value to the end, and remove oldest one
|
||||
const removed = this.push(nextValue);
|
||||
|
||||
// smooth value using all values from buffer
|
||||
const result = this.buffer.reduce(this._nextReduce, removed);
|
||||
|
||||
// replace smoothed value
|
||||
this.buffer[this.buffer.length - 1] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_nextReduce = (last: number, current: number): number => {
|
||||
return this.smoothing * current + (1 - this.smoothing) * last;
|
||||
};
|
||||
}
|
||||
15
desktop/flipper-plugin/src/ui/utils/Rect.tsx
Normal file
15
desktop/flipper-plugin/src/ui/utils/Rect.tsx
Normal file
@@ -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;
|
||||
};
|
||||
160
desktop/flipper-plugin/src/ui/utils/snap.tsx
Normal file
160
desktop/flipper-plugin/src/ui/utils/snap.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 {Rect} from './Rect';
|
||||
|
||||
export const SNAP_SIZE = 16;
|
||||
|
||||
export function snapGrid(val: number): number {
|
||||
return val - (val % SNAP_SIZE);
|
||||
}
|
||||
|
||||
export function getPossibleSnappedPosition(
|
||||
windows: Array<Rect>,
|
||||
{
|
||||
getGap,
|
||||
getNew,
|
||||
}: {
|
||||
getNew: (win: Rect) => number;
|
||||
getGap: (win: Rect) => number;
|
||||
},
|
||||
): number | undefined {
|
||||
for (const win of windows) {
|
||||
const gap = Math.abs(getGap(win));
|
||||
if (gap >= 0 && gap < SNAP_SIZE) {
|
||||
return getNew(win);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDistanceTo(props: Rect, win: Rect): number {
|
||||
const x1 = win.left;
|
||||
const y1 = win.top;
|
||||
const x1b = win.left + win.width;
|
||||
const y1b = win.top + win.height;
|
||||
|
||||
const x2 = props.left;
|
||||
const y2 = props.top;
|
||||
const x2b = props.left + props.width;
|
||||
const y2b = props.top + props.height;
|
||||
|
||||
const left = x2b < x1;
|
||||
const right = x1b < x2;
|
||||
const bottom = y2b < y1;
|
||||
const top = y1b < y2;
|
||||
|
||||
if (top && left) {
|
||||
return distance(x1, y1b, x2b, y2);
|
||||
} else if (left && bottom) {
|
||||
return distance(x1, y1, x2b, y2b);
|
||||
} else if (bottom && right) {
|
||||
return distance(x1b, y1, x2, y2b);
|
||||
} else if (right && top) {
|
||||
return distance(x1b, y1b, x2, y2);
|
||||
} else if (left) {
|
||||
return x1 - x2b;
|
||||
} else if (right) {
|
||||
return x2 - x1b;
|
||||
} else if (bottom) {
|
||||
return y1 - y2b;
|
||||
} else if (top) {
|
||||
return y2 - y1b;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function distance(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
): number {
|
||||
return Math.abs(x1 - x2) + Math.abs(y1 - y2);
|
||||
}
|
||||
|
||||
export function maybeSnapLeft(
|
||||
props: Rect,
|
||||
windows: Array<Rect>,
|
||||
left: number,
|
||||
): number {
|
||||
// snap right side to left
|
||||
// ┌─┬─┐
|
||||
// │A│B│
|
||||
// └─┴─┘
|
||||
const snapRight = getPossibleSnappedPosition(windows, {
|
||||
getGap: (win) => win.left - (props.width + left),
|
||||
getNew: (win) => win.left - props.width,
|
||||
});
|
||||
if (snapRight != null) {
|
||||
return snapRight;
|
||||
}
|
||||
|
||||
// snap left side to right
|
||||
// ┌─┬─┐
|
||||
// │B│A│
|
||||
// └─┴─┘
|
||||
const snapLeft = getPossibleSnappedPosition(windows, {
|
||||
getGap: (win) => left - (win.left + win.width),
|
||||
getNew: (win) => win.left + win.width,
|
||||
});
|
||||
if (snapLeft != null) {
|
||||
return snapLeft;
|
||||
}
|
||||
|
||||
return snapGrid(left);
|
||||
}
|
||||
|
||||
export function maybeSnapTop(
|
||||
_props: Rect,
|
||||
windows: Array<Rect>,
|
||||
top: number,
|
||||
): number {
|
||||
// snap bottom to bottom
|
||||
// ┌─┐
|
||||
// │A├─┐
|
||||
// │ │B│
|
||||
// └─┴─┘
|
||||
const snapBottom2 = getPossibleSnappedPosition(windows, {
|
||||
getGap: (win) => top - win.top - win.height,
|
||||
getNew: (win) => win.top + win.height,
|
||||
});
|
||||
if (snapBottom2 != null) {
|
||||
return snapBottom2;
|
||||
}
|
||||
|
||||
// snap top to bottom
|
||||
// ┌─┐
|
||||
// │B│
|
||||
// ├─┤
|
||||
// │A│
|
||||
// └─┘
|
||||
const snapBottom = getPossibleSnappedPosition(windows, {
|
||||
getGap: (win) => top - win.top - win.height,
|
||||
getNew: (win) => win.top + win.height,
|
||||
});
|
||||
if (snapBottom != null) {
|
||||
return snapBottom;
|
||||
}
|
||||
|
||||
// snap top to top
|
||||
// ┌─┬─┐
|
||||
// │A│B│
|
||||
// │ ├─┘
|
||||
// └─┘
|
||||
const snapTop = getPossibleSnappedPosition(windows, {
|
||||
getGap: (win) => top - win.top,
|
||||
getNew: (win) => win.top,
|
||||
});
|
||||
if (snapTop != null) {
|
||||
return snapTop;
|
||||
}
|
||||
|
||||
return snapGrid(top);
|
||||
}
|
||||
23
desktop/flipper-plugin/src/ui/utils/widthUtils.tsx
Normal file
23
desktop/flipper-plugin/src/ui/utils/widthUtils.tsx
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user