Move app/src (mostly) to flipper-ui-core/src

Summary:
This diff moves all UI code from app/src to app/flipper-ui-core. That is now slightly too much (e.g. node deps are not removed yet), but from here it should be easier to move things out again, as I don't want this diff to be open for too long to avoid too much merge conflicts.

* But at least flipper-ui-core is Electron free :)
* Killed all cross module imports as well, as they where now even more in the way
* Some unit test needed some changes, most not too big (but emotion hashes got renumbered in the snapshots, feel free to ignore that)
* Found some files that were actually meaningless (tsconfig in plugins, WatchTools files, that start generating compile errors, removed those

Follow up work:
* make flipper-ui-core configurable, and wire up flipper-server-core in Electron instead of here
* remove node deps (aigoncharov)
* figure out correct place to load GKs, plugins, make intern requests etc., and move to the correct module
* clean up deps

Reviewed By: aigoncharov

Differential Revision: D32427722

fbshipit-source-id: 14fe92e1ceb15b9dcf7bece367c8ab92df927a70
This commit is contained in:
Michel Weststrate
2021-11-16 05:25:40 -08:00
committed by Facebook GitHub Bot
parent 54b7ce9308
commit 7e50c0466a
293 changed files with 483 additions and 497 deletions

View File

@@ -0,0 +1,148 @@
/**
* 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, {useCallback} from 'react';
import styled from '@emotion/styled';
import {Button as AntdButton} from 'antd';
import Glyph, {IconSize} from './Glyph';
import type {ButtonProps} from 'antd/lib/button';
import {theme, getFlipperLib} from 'flipper-plugin';
type ButtonType = 'primary' | 'success' | 'warning' | 'danger';
const Icon = styled(Glyph)<{hasText: boolean}>(({hasText}) => ({
marginRight: hasText ? 3 : 0,
}));
Icon.displayName = 'Button:Icon';
type Props = {
/**
* onMouseUp handler.
*/
onMouseDown?: (event: React.MouseEvent) => any;
/**
* onClick handler.
*/
onClick?: (event: React.MouseEvent) => any;
/**
* Whether this button is disabled.
*/
disabled?: boolean;
/**
* Whether this button is large. Increases padding and line-height.
*/
large?: boolean;
/**
* Whether this button is compact. Decreases padding and line-height.
*/
compact?: boolean;
/**
* Type of button.
*/
type?: ButtonType; // TODO: normalize to Sandy
/**
* Children.
*/
children?: React.ReactNode;
/**
* Name of the icon dispalyed next to the text
*/
icon?: string;
/**
* Size of the icon in pixels.
*/
iconSize?: IconSize;
/**
* For toggle buttons, if the button is selected
*/
selected?: boolean;
/**
* Button is pulsing
*/
pulse?: boolean;
/**
* URL to open in the browser on click
*/
href?: string;
/**
* Whether the button should render depressed into its socket
*/
depressed?: boolean;
/**
* Style of the icon. `filled` is the default
*/
iconVariant?: 'filled' | 'outline';
/**
* Whether the button should have additional padding left and right.
*/
padded?: boolean;
} & Omit<ButtonProps, 'type'>;
/**
* A simple button, used in many parts of the application.
* @deprecated use import {Button} from `antd` instead.
*/
export default function Button(props: Props) {
return <SandyButton {...props} />;
}
/**
* A simple button, used in many parts of the application.
*/
function SandyButton({
compact,
disabled,
icon,
children,
iconSize,
iconVariant,
type,
onClick,
href,
...restProps
}: Props) {
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (disabled === true) {
return;
}
onClick?.(e);
if (href != null) {
getFlipperLib().openLink(href);
}
},
[disabled, onClick, href],
);
let iconComponent;
if (icon != null) {
iconComponent = (
<Icon
name={icon}
size={iconSize || (compact === true ? 12 : 16)}
color={theme.textColorPrimary}
variant={iconVariant || 'filled'}
hasText={Boolean(children)}
/>
);
}
return (
<AntdButton
/* Probably more properties need passing on, but lets be explicit about it */
style={restProps.style}
disabled={disabled}
type={type === 'primary' ? 'primary' : 'default'}
danger={type === 'danger'}
onClick={handleClick}
icon={iconComponent}>
{children}
</AntdButton>
);
}

View File

@@ -0,0 +1,43 @@
/**
* 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, {createContext} from 'react';
import {Space} from 'antd';
const ButtonGroupContainer = styled.div({
display: 'inline-flex',
marginLeft: 10,
'&:first-child': {
marginLeft: 0,
},
});
ButtonGroupContainer.displayName = 'ButtonGroup:ButtonGroupContainer';
export const ButtonGroupContext = createContext(false);
/**
* Group a series of buttons together.
*
* ```jsx
* <ButtonGroup>
* <Button>One</Button>
* <Button>Two</Button>
* <Button>Three</Button>
* </ButtonGroup>
* ```
* @deprecated use Layout.Horizontal with flags: gap pad wrap
*/
export default function ButtonGroup({children}: {children: React.ReactNode}) {
return (
<ButtonGroupContext.Provider value>
<Space>{children}</Space>
</ButtonGroupContext.Provider>
);
}

View File

@@ -0,0 +1,33 @@
/**
* 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 from 'react';
import {Layout, theme} from 'flipper-plugin';
/**
* CenteredView creates a scrollable container with fixed with, centered content.
* Recommended to combine with RoundedSection
* @deprecated
*/
const CenteredView: React.FC<{}> = ({children}) => (
<Layout.ScrollContainer style={{background: theme.backgroundWash}}>
<Layout.Container
center
padv={theme.space.huge}
width={500}
style={{
marginLeft: 'auto',
marginRight: 'auto',
}}>
{children}
</Layout.Container>
</Layout.ScrollContainer>
);
export default CenteredView;

View File

@@ -0,0 +1,48 @@
/**
* 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 {PureComponent} from 'react';
import styled from '@emotion/styled';
import React from 'react';
type CheckboxProps = {
/** Whether the checkbox is checked. */
checked: boolean;
/** Called when a state change is triggered */
onChange: (checked: boolean) => void;
disabled?: boolean;
};
const CheckboxContainer = styled.input({
display: 'inline-block',
marginRight: 5,
verticalAlign: 'middle',
});
CheckboxContainer.displayName = 'Checkbox:CheckboxContainer';
/**
* A checkbox to toggle UI state
* @deprecated use Checkbox from 'antd' instead
*/
export default class Checkbox extends PureComponent<CheckboxProps> {
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.props.onChange(e.target.checked);
};
render() {
return (
<CheckboxContainer
type="checkbox"
checked={this.props.checked}
onChange={this.onChange}
disabled={this.props.disabled}
/>
);
}
}

View File

@@ -0,0 +1,110 @@
/**
* 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 * as React from 'react';
import {Menu, Dropdown} from 'antd';
import {createElement, useCallback, forwardRef, Ref, ReactElement} from 'react';
import FlexColumn from './FlexColumn';
import {CheckOutlined} from '@ant-design/icons';
export type ContextMenuItem =
| {
readonly label: string;
readonly click?: () => void;
readonly role?: string;
readonly enabled?: boolean;
readonly type?: 'normal' | 'checkbox';
readonly checked?: boolean;
}
| {
readonly type: 'separator';
}
| {
readonly label: string;
readonly submenu: MenuTemplate;
};
export type MenuTemplate = ReadonlyArray<ContextMenuItem>;
type Props<C> = {
/** List of items in the context menu. Used for static menus. */
items?: MenuTemplate;
/** Function to generate the menu. Called right before the menu is showed. Used for dynamic menus. */
buildItems?: () => MenuTemplate;
/** Nodes that should have a context menu */
children: React.ReactNode;
/** The component that is used to wrap the children. Defaults to `FlexColumn`. */
component?: React.ComponentType<any> | string;
onMouseDown?: (e: React.MouseEvent) => any;
} & C;
const contextMenuTrigger = ['contextMenu' as const];
/**
* Native context menu that is shown on secondary click.
* Uses [Electron's context menu API](https://electronjs.org/docs/api/menu-item)
* to show menu items.
*
* Separators can be added by `{type: 'separator'}`
* @depreacted https://ant.design/components/dropdown/#components-dropdown-demo-context-menu
*/
export default forwardRef(function ContextMenu<C>(
{items, buildItems, component, children, ...otherProps}: Props<C>,
ref: Ref<any> | null,
) {
const onContextMenu = useCallback(() => {
return createContextMenu(items ?? buildItems?.() ?? []);
}, [items, buildItems]);
return (
<Dropdown overlay={onContextMenu} trigger={contextMenuTrigger}>
{createElement(
component || FlexColumn,
{
ref,
...otherProps,
},
children,
)}
</Dropdown>
);
}) as <T>(p: Props<T> & {ref?: Ref<any>}) => ReactElement;
export function createContextMenu(items: MenuTemplate) {
return <Menu>{items.map(createMenuItem)}</Menu>;
}
function createMenuItem(item: ContextMenuItem, idx: number) {
if ('type' in item && item.type === 'separator') {
return <Menu.Divider key={idx} />;
} else if ('submenu' in item) {
return (
<Menu.SubMenu key={idx} title={item.label}>
{item.submenu.map(createMenuItem)}
</Menu.SubMenu>
);
} else if ('label' in item) {
return (
<Menu.Item
key={idx}
onClick={item.click}
disabled={item.enabled === false}
role={item.role}
icon={
item.type === 'checkbox' ? (
<CheckOutlined
style={{visibility: item.checked ? 'visible' : 'hidden'}}
/>
) : undefined
}>
{item.label}
</Menu.Item>
);
}
}

View File

@@ -0,0 +1,48 @@
/**
* 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 {CodeBlock} from 'flipper-plugin';
export const ErrorBlockContainer = styled(CodeBlock)({
backgroundColor: '#f2dede',
border: '1px solid #ebccd1',
borderRadius: 4,
color: '#a94442',
overflow: 'auto',
padding: 10,
whiteSpace: 'pre',
});
ErrorBlockContainer.displayName = 'ErrorBlock:ErrorBlockContainer';
/**
* Displaying error messages in a red box.
*/
export default class ErrorBlock extends React.Component<{
/** Error message to display. Error object's `stack` or `message` property is used. */
error: Error | string | null;
/** Additional className added to the container. */
className?: string;
}> {
render() {
const {className, error} = this.props;
let stack = 'Unknown Error';
if (typeof error === 'string') {
stack = error;
} else if (error && typeof error === 'object') {
stack = error.stack || error.message || stack;
}
return (
<ErrorBlockContainer className={className}>{stack}</ErrorBlockContainer>
);
}
}

View File

@@ -0,0 +1,93 @@
/**
* 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 {CodeBlock} from 'flipper-plugin';
import {Component} from 'react';
import Heading from './Heading';
import Button from './Button';
import View from './View';
import styled from '@emotion/styled';
import React from 'react';
const ErrorBoundaryContainer = styled(View)({
overflow: 'auto',
padding: 10,
});
ErrorBoundaryContainer.displayName = 'ErrorBoundary:ErrorBoundaryContainer';
const ErrorBoundaryStack = styled(CodeBlock)({
marginBottom: 10,
whiteSpace: 'pre',
});
ErrorBoundaryStack.displayName = 'ErrorBoundary:ErrorBoundaryStack';
type ErrorBoundaryProps = {
/** Function to dynamically generate the heading of the ErrorBox. */
buildHeading?: (err: Error) => string;
/** Heading of the ErrorBox. Used as an alternative to `buildHeading`. */
heading?: string;
/** Whether the stacktrace of the error is shown in the error box */
showStack?: boolean;
/** Code that might throw errors that will be catched */
children?: React.ReactNode;
};
type ErrorBoundaryState = {
error: Error | null | undefined;
};
/**
* Boundary catching errors and displaying an ErrorBlock instead.
*/
export default class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps, context: Object) {
super(props, context);
this.state = {error: null};
}
componentDidCatch(err: Error) {
console.error(err.toString(), 'ErrorBoundary');
this.setState({error: err});
}
clearError = () => {
this.setState({error: null});
};
render() {
const {error} = this.state;
if (error) {
const {buildHeading} = this.props;
let {heading} = this.props;
if (buildHeading) {
heading = buildHeading(error);
}
if (heading == null) {
heading = 'An error has occured';
}
return (
<ErrorBoundaryContainer grow>
<Heading>{heading}</Heading>
{this.props.showStack !== false && (
<ErrorBoundaryStack>{`${
error.stack ?? error.toString()
}`}</ErrorBoundaryStack>
)}
<Button onClick={this.clearError}>Clear error and try again</Button>
</ErrorBoundaryContainer>
);
} else {
return this.props.children || null;
}
}
}

View File

@@ -0,0 +1,217 @@
/**
* 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 {Component} from 'react';
import path from 'path';
import fs from 'fs';
const EMPTY_MAP = new Map();
const EMPTY_FILE_LIST_STATE = {error: null, files: EMPTY_MAP};
export type FileListFileType = 'file' | 'folder';
export type FileListFile = {
name: string;
src: string;
type: FileListFileType;
size: number;
mtime: number;
atime: number;
ctime: number;
birthtime: number;
};
export type FileListFiles = Array<FileListFile>;
type FileListProps = {
/** Path to the folder */
src: string;
/** Content to be rendered in case of an error */
onError?: (err: Error) => React.ReactNode | null | undefined;
/** Content to be rendered while loading */
onLoad?: () => void;
/** Content to be rendered when the file list is loaded */
onFiles: (files: FileListFiles) => React.ReactNode;
};
type FileListState = {
files: Map<string, FileListFile>;
error: Error | null | undefined;
};
/**
* List the contents of a folder from the user's file system. The file system is watched for
* changes and this list will automatically update.
*/
export default class FileList extends Component<FileListProps, FileListState> {
constructor(props: FileListProps, context: Object) {
super(props, context);
this.state = EMPTY_FILE_LIST_STATE;
}
watcher: fs.FSWatcher | null | undefined;
fetchFile(src: string, name: string): Promise<FileListFile> {
return new Promise((resolve, reject) => {
const loc = path.join(src, name);
fs.lstat(loc, (err, stat) => {
if (err) {
reject(err);
} else {
const details: FileListFile = {
atime: Number(stat.atime),
birthtime:
typeof stat.birthtime === 'object' ? Number(stat.birthtime) : 0,
ctime: Number(stat.ctime),
mtime: Number(stat.mtime),
name,
size: stat.size,
src: loc,
type: stat.isDirectory() ? 'folder' : 'file',
};
resolve(details);
}
});
});
}
fetchFilesFromFolder(
originalSrc: string,
currentSrc: string,
callback: Function,
) {
const hasChangedDir = () => this.props.src !== originalSrc;
let filesSet: Map<string, FileListFile> = new Map();
fs.readdir(currentSrc, (err, files) => {
if (err) {
return callback(err, EMPTY_MAP);
}
let remainingPaths = files.length;
const next = () => {
if (hasChangedDir()) {
return callback(null, EMPTY_MAP);
}
if (!remainingPaths) {
return callback(null, filesSet);
}
const name = files.shift();
if (name) {
this.fetchFile(currentSrc, name)
.then((data) => {
filesSet.set(name, data);
if (data.type == 'folder') {
this.fetchFilesFromFolder(
originalSrc,
path.join(currentSrc, name),
function (err: Error, files: Map<string, FileListFile>) {
if (err) {
return callback(err, EMPTY_MAP);
}
filesSet = new Map([...filesSet, ...files]);
remainingPaths--;
if (!remainingPaths) {
return callback(null, filesSet);
}
},
);
} else {
remainingPaths--;
}
next();
})
.catch((err) => {
return callback(err, EMPTY_MAP);
});
}
};
next();
});
}
fetchFiles(callback?: Function) {
const {src} = this.props;
const setState = (data: FileListState) => {
if (!hasChangedDir()) {
this.setState(data);
}
};
const hasChangedDir = () => this.props.src !== src;
this.fetchFilesFromFolder(
src,
src,
function (err: Error, files: Map<string, FileListFile>) {
setState({error: err, files: files});
if (callback) {
callback();
}
},
);
}
UNSAFE_componentWillReceiveProps(nextProps: FileListProps) {
if (nextProps.src !== this.props.src) {
this.initialFetch(nextProps);
}
}
componentDidMount() {
this.initialFetch(this.props);
}
componentWillUnmount() {
this.removeWatcher();
}
initialFetch(props: FileListProps) {
this.removeWatcher();
fs.access(props.src, fs.constants.R_OK, (err) => {
if (err) {
this.setState({error: err, files: EMPTY_MAP});
return;
}
this.fetchFiles(props.onLoad);
this.watcher = fs.watch(props.src, () => {
this.fetchFiles();
});
this.watcher.on('error', (err) => {
this.setState({error: err, files: EMPTY_MAP});
this.removeWatcher();
});
});
}
removeWatcher() {
if (this.watcher) {
this.watcher.close();
}
}
render() {
const {error, files} = this.state;
const {onError, onFiles} = this.props;
if (error && onError) {
return onError(error);
} else {
return onFiles(Array.from(files.values()));
}
}
}

View File

@@ -0,0 +1,119 @@
/**
* 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, {useState} from 'react';
import FlexRow from './FlexRow';
import Glyph from './Glyph';
import Input from './Input';
import styled from '@emotion/styled';
import {colors} from './colors';
import fs from 'fs';
import {Tooltip} from '..';
import {getFlipperLib} from 'flipper-plugin';
const CenteredGlyph = styled(Glyph)({
margin: 'auto',
marginLeft: 4,
});
const Container = styled(FlexRow)({
width: '100%',
marginRight: 4,
});
const GlyphContainer = styled(FlexRow)({
width: 20,
});
const FileInputBox = styled(Input)<{isValid: boolean}>(({isValid}) => ({
flexGrow: 1,
color: isValid ? undefined : colors.red,
'&::-webkit-input-placeholder': {
color: colors.placeholder,
fontWeight: 300,
},
}));
export interface Props {
onPathChanged: (evtArgs: {path: string; isValid: boolean}) => void;
placeholderText: string;
defaultPath: string;
}
const defaultProps: Props = {
onPathChanged: (_) => {},
placeholderText: '',
defaultPath: '/',
};
export default function FileSelector({
onPathChanged,
placeholderText,
defaultPath,
}: Props) {
const [value, setValue] = useState('');
const [isValid, setIsValid] = useState(false);
const onChange = (path: string) => {
setValue(path);
let isNewPathValid = false;
try {
isNewPathValid = fs.statSync(path).isFile();
} catch {
isNewPathValid = false;
}
setIsValid(isNewPathValid);
onPathChanged({path, isValid: isNewPathValid});
};
return (
<Container>
<FileInputBox
placeholder={placeholderText}
value={value}
isValid
onDrop={(e) => {
if (e.dataTransfer.files.length) {
onChange(e.dataTransfer.files[0].path);
}
}}
onChange={(e) => {
onChange(e.target.value);
}}
/>
<GlyphContainer
onClick={() =>
getFlipperLib()
.showOpenDialog?.({defaultPath})
.then((path) => {
if (path) {
onChange(path);
}
})
}>
<CenteredGlyph
name="dots-3-circle"
variant="outline"
title="Open file selection dialog"
/>
</GlyphContainer>
<GlyphContainer>
{isValid ? null : (
<Tooltip title="The specified path is invalid or such file does not exist">
<CenteredGlyph
name="caution-triangle"
color={colors.yellow}
size={16}
/>
</Tooltip>
)}
</GlyphContainer>
</Container>
);
}
FileSelector.defaultProps = defaultProps;

View File

@@ -0,0 +1,28 @@
/**
* 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 View from './View';
import styled from '@emotion/styled';
type Props = {
/** Flexbox's shrink property. Set to `0`, to disable shrinking. */
shrink?: boolean;
};
/**
* @deprecated use `Layout.Container` from flipper-plugin instead
* A container using flexbox to layout its children
*/
const FlexBox = styled(View)<Props>(({shrink}) => ({
display: 'flex',
flexShrink: shrink == null || shrink ? 1 : 0,
}));
FlexBox.displayName = 'FlexBox';
export default FlexBox;

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import View from './View';
import styled from '@emotion/styled';
/**
* @deprecated use `Layout.Container` from flipper-plugin instead
* A container displaying its children horizontally and vertically centered.
*/
const FlexCenter = styled(View)({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
FlexCenter.displayName = 'FlexCenter';
export default FlexCenter;

View File

@@ -0,0 +1,22 @@
/**
* 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 FlexBox from './FlexBox';
import styled from '@emotion/styled';
/**
* @deprecated use `Layout.Container` from flipper-plugin instead
* A container displaying its children in a column
*/
const FlexColumn = styled(FlexBox)({
flexDirection: 'column',
});
FlexColumn.displayName = 'FlexColumn';
export default FlexColumn;

View File

@@ -0,0 +1,22 @@
/**
* 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 FlexBox from './FlexBox';
import styled from '@emotion/styled';
/**
* @deprecated use `Layout.Horizontal` from flipper-plugin instead
* A container displaying its children in a row
*/
const FlexRow = styled(FlexBox)({
flexDirection: 'row',
});
FlexRow.displayName = 'FlexRow';
export default FlexRow;

View File

@@ -0,0 +1,133 @@
/**
* 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 from 'react';
import styled from '@emotion/styled';
import {getIconURLSync} from '../../utils/icons';
export type IconSize = 8 | 10 | 12 | 16 | 18 | 20 | 24 | 32;
const ColoredIconBlack = styled.img<{size: number}>(({size}) => ({
height: size,
verticalAlign: 'middle',
width: size,
flexShrink: 0,
}));
ColoredIconBlack.displayName = 'Glyph:ColoredIconBlack';
const ColoredIconCustom = styled.div<{
size: number;
color?: string;
src: string;
}>((props) => ({
height: props.size,
verticalAlign: 'middle',
width: props.size,
backgroundColor: props.color,
display: 'inline-block',
maskImage: `url('${props.src}')`,
maskSize: '100% 100%',
WebkitMaskImage: `url('${props.src}')`,
WebkitMaskSize: '100% 100%',
flexShrink: 0,
}));
ColoredIconCustom.displayName = 'Glyph:ColoredIconCustom';
function ColoredIcon(
props: {
name: string;
src: string;
size?: number;
className?: string;
color?: string;
style?: React.CSSProperties;
title?: string;
},
context: {
glyphColor?: string;
},
) {
const {
color = context.glyphColor,
name,
size = 16,
src,
style,
title,
} = props;
const isBlack =
color == null ||
color === '#000' ||
color === 'black' ||
color === '#000000';
if (isBlack) {
return (
<ColoredIconBlack
alt={name}
src={src}
size={size}
className={props.className}
style={style}
title={title}
/>
);
} else {
return (
<ColoredIconCustom
color={color}
size={size}
src={src}
className={props.className}
style={style}
title={title}
/>
);
}
}
ColoredIcon.displayName = 'Glyph:ColoredIcon';
export default class Glyph extends React.PureComponent<{
name: string;
size?: IconSize;
variant?: 'filled' | 'outline';
className?: string;
color?: string;
style?: React.CSSProperties;
title?: string;
}> {
render() {
const {
name,
size = 16,
variant,
color,
className,
style,
title,
} = this.props;
return (
<ColoredIcon
name={name}
className={className}
color={color}
size={size}
title={title}
src={getIconURLSync(
variant === 'outline' ? `${name}-outline` : name,
size,
typeof window !== 'undefined' ? window.devicePixelRatio : 1,
)}
style={style}
/>
);
}
}

View File

@@ -0,0 +1,87 @@
/**
* 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 from 'react';
import styled from '@emotion/styled';
import FlexRow from './FlexRow';
/**
* Container that applies a standardized right margin for horizontal spacing
* It takes two children, 'left' and 'right'. One is assumed to have a fixed (or minimum) size,
* and the other will grow automatically
*/
const HBoxContainer = styled(FlexRow)<{verticalAlign: string}>(
({verticalAlign}) => ({
shrink: 0,
alignItems: verticalAlign,
}),
);
HBoxContainer.displayName = 'HBoxContainer';
/**
* @deprecated use Layout.Left / Layout.Right or Layout.Horizonta from flipper-plugin instead
*/
const HBox: React.FC<{
children: [] | [React.ReactNode] | [React.ReactNode, React.ReactNode];
grow?: 'left' | 'right' | 'auto';
childWidth?: number;
verticalAlign?: 'center' | 'top';
}> = ({children, grow, childWidth, verticalAlign}) => {
if (children.length > 2) {
throw new Error('HBox expects at most 2 children');
}
const left = children[0] || null;
const right = children[1] || null;
const fixedStyle = {
width: childWidth ? `${childWidth}px` : 'auto',
grow: 0,
shrink: 0,
};
const growStyle = {
flexShrink: 1,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
} as const;
const vAlign = verticalAlign === 'top' ? 'normal' : 'center';
switch (grow) {
case 'right':
return (
<HBoxContainer verticalAlign={vAlign}>
<div style={{...fixedStyle, marginRight: 8}}>{left}</div>
<div style={growStyle}>{right}</div>
</HBoxContainer>
);
case 'left':
return (
<HBoxContainer verticalAlign={vAlign}>
<div style={growStyle}>{left}</div>
<div style={{...fixedStyle, marginLeft: 8}}>{right}</div>
</HBoxContainer>
);
default:
return (
<HBoxContainer verticalAlign={vAlign}>
<div style={growStyle}>{left}</div>
<div style={{...growStyle, marginLeft: 8}}>{right}</div>
</HBoxContainer>
);
}
};
HBox.defaultProps = {
grow: 'right',
childWidth: 0,
verticalAlign: 'center',
};
HBox.displayName = 'HBox';
export default HBox;

View File

@@ -0,0 +1,49 @@
/**
* 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';
const LargeHeading = styled.div({
fontSize: 18,
fontWeight: 'bold',
lineHeight: '20px',
borderBottom: '1px solid #ddd',
marginBottom: 10,
});
LargeHeading.displayName = 'Heading:LargeHeading';
const SmallHeading = styled.div({
fontSize: 12,
color: '#90949c',
fontWeight: 'bold',
marginBottom: 10,
textTransform: 'uppercase',
});
SmallHeading.displayName = 'Heading:SmallHeading';
/**
* A heading component.
*/
export default function Heading(props: {
/**
* Level of the heading. A number from 1-6. Where 1 is the largest heading.
*/
level?: number;
/**
* Children.
*/
children?: React.ReactNode;
}) {
if (props.level === 1) {
return <LargeHeading>{props.children}</LargeHeading>;
} else {
return <SmallHeading>{props.children}</SmallHeading>;
}
}

View File

@@ -0,0 +1,19 @@
/**
* 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';
const HorizontalRule = styled.div({
backgroundColor: '#c9ced4',
height: 1,
margin: '5px 0',
});
HorizontalRule.displayName = 'HorizontalRule';
export default HorizontalRule;

View File

@@ -0,0 +1,86 @@
/**
* 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 {colors} from './colors';
import HBox from './HBox';
import Glyph from './Glyph';
import LoadingIndicator from './LoadingIndicator';
import FlexColumn from './FlexColumn';
export type InfoProps = {
children: React.ReactNode;
type: 'info' | 'spinning' | 'warning' | 'error';
small?: boolean;
};
const icons = {
info: 'info-circle',
warning: 'caution-triangle',
error: 'cross-circle',
};
const color = {
info: colors.aluminumDark3,
warning: colors.yellow,
error: colors.red,
spinning: colors.light30,
};
const bgColor = {
info: colors.cyanTint,
warning: colors.yellowTint,
error: colors.redTint,
spinning: 'transparent',
};
const InfoWrapper = styled(FlexColumn)<Pick<InfoProps, 'type' | 'small'>>(
({type, small}) => ({
padding: small ? '0 4px' : 10,
borderRadius: 4,
color: color[type],
border: `1px solid ${color[type]}`,
background: bgColor[type],
width: '100%',
}),
);
InfoWrapper.displayName = 'InfoWrapper';
/**
* Shows an info box with some text and a symbol.
* Supported types: info | spinning | warning | error
*/
function Info({type, children, small}: InfoProps) {
return (
<InfoWrapper type={type} small={small}>
<HBox>
{type === 'spinning' ? (
<LoadingIndicator size={small ? 12 : 24} />
) : (
<Glyph
name={icons[type]}
color={color[type]}
size={small ? 12 : 24}
variant="filled"
/>
)}
<div>{children}</div>
</HBox>
</InfoWrapper>
);
}
Info.defaultProps = {
type: 'info',
};
export default Info;

View File

@@ -0,0 +1,54 @@
/**
* 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 {theme} from 'flipper-plugin';
export const inputStyle = (props: {
compact: boolean;
valid: boolean;
readOnly: boolean;
}) => ({
border: `1px solid ${props.valid ? theme.dividerColor : theme.errorColor}`,
borderRadius: 4,
font: 'inherit',
fontSize: '1em',
height: props.compact ? '17px' : '28px',
lineHeight: props.compact ? '17px' : '28px',
backgroundColor: props.readOnly
? theme.backgroundWash
: theme.backgroundDefault,
'&:disabled': {
backgroundColor: theme.disabledColor,
borderColor: theme.disabledColor,
cursor: 'not-allowed',
},
});
const Input = styled.input<{
compact?: boolean;
valid?: boolean;
readOnly?: boolean;
}>(({compact, valid, readOnly}) => ({
...inputStyle({
compact: compact || false,
valid: valid !== false,
readOnly: readOnly === true,
}),
padding: compact ? '0 5px' : '0 10px',
}));
Input.displayName = 'Input';
Input.defaultProps = {
type: 'text',
};
export default Input;

View File

@@ -0,0 +1,18 @@
/**
* 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';
const Label = styled.div({
fontSize: 12,
fontWeight: 'bold',
});
Label.displayName = 'Label';
export default Label;

View File

@@ -0,0 +1,28 @@
/**
* 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 from 'react';
import Label from './Label';
import VBox from './VBox';
import FlexColumn from './FlexColumn';
/**
* Vertically arranged section that starts with a label and includes standard margins
*/
const Labeled: React.FC<{title: string | React.ReactNode}> = ({
title,
children,
}) => (
<VBox>
<Label style={{marginBottom: 6}}>{title}</Label>
<FlexColumn>{children}</FlexColumn>
</VBox>
);
export default Labeled;

View File

@@ -0,0 +1,20 @@
/**
* 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 View from './View';
import {colors} from './colors';
const Line = styled(View)<{color?: string}>(({color}) => ({
backgroundColor: color ? color : colors.grayTint2,
height: 1,
width: 'auto',
flexShrink: 0,
}));
export default Line;

View File

@@ -0,0 +1,21 @@
/**
* 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 {getFlipperLib} from 'flipper-plugin';
import {Typography} from 'antd';
const AntOriginalLink = Typography.Link;
// used by patch for Typography.Link in AntD
// @ts-ignore
global.flipperOpenLink = function openLinkExternal(url: string) {
getFlipperLib().openLink(url);
};
export default AntOriginalLink;

View File

@@ -0,0 +1,39 @@
/**
* 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 {keyframes} from '@emotion/css';
const animation = keyframes({
'0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(360deg)',
},
});
const LoadingIndicator = styled.div<{size: number}>((props) => ({
animation: `${animation} 1s infinite linear`,
width: props.size,
height: props.size,
minWidth: props.size,
minHeight: props.size,
borderRadius: '50%',
border: `${props.size / 6}px solid rgba(0, 0, 0, 0.2)`,
borderLeftColor: 'rgba(0, 0, 0, 0.4)',
}));
LoadingIndicator.displayName = 'LoadingIndicator';
LoadingIndicator.defaultProps = {
size: 50,
};
export default LoadingIndicator;

View File

@@ -0,0 +1,103 @@
/**
* 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, {CSSProperties, ReactNode} from 'react';
import styled from '@emotion/styled';
import ReactMarkdown from 'react-markdown';
import {getFlipperLib, theme} from 'flipper-plugin';
const Container = styled.div({
padding: 10,
});
const Row = styled.div({
marginTop: 5,
marginBottom: 5,
lineHeight: 1.34,
});
const Heading = styled.div({fontSize: 18, marginTop: 10, marginBottom: 10});
const SubHeading = styled.div({
fontSize: 12,
textTransform: 'uppercase',
color: theme.textColorSecondary,
marginTop: 10,
marginBottom: 10,
fontWeight: 'bold',
});
const ListItem = styled.li({
listStyleType: 'circle',
listStylePosition: 'inside',
marginLeft: 10,
});
const Strong = styled.span({
fontWeight: 'bold',
color: theme.textColorPrimary,
});
const Emphasis = styled.span({
fontStyle: 'italic',
});
const Quote = styled(Row)({
padding: 10,
backgroundColor: theme.backgroundWash,
fontSize: 13,
});
const Code = styled.span({
fontFamily: '"Courier New", Courier, monospace',
backgroundColor: theme.backgroundWash,
});
const Pre = styled(Row)({
padding: 10,
backgroundColor: theme.backgroundWash,
});
function CodeBlock(props: {
children: ReactNode[];
className?: string;
inline?: boolean;
}) {
return props.inline ? (
<Code>{props.children}</Code>
) : (
<Pre>
<Code>{props.children}</Code>
</Pre>
);
}
const Link = styled.span({
color: theme.textColorActive,
});
function LinkReference(props: {href: string; children: Array<ReactNode>}) {
return (
<Link onClick={() => getFlipperLib().openLink(props.href)}>
{props.children}
</Link>
);
}
export function Markdown(props: {source: string; style?: CSSProperties}) {
return (
<Container style={props.style}>
<ReactMarkdown
components={{
h1: Heading,
h2: SubHeading,
h3: 'h2',
li: ListItem,
p: Row,
strong: Strong,
em: Emphasis,
code: CodeBlock,
// @ts-ignore missing in declaration
blockquote: Quote,
// @ts-ignore props missing href but existing run-time
a: LinkReference,
}}>
{props.source}
</ReactMarkdown>
</Container>
);
}

View File

@@ -0,0 +1,53 @@
/**
* 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 {Component} from 'react';
import React from 'react';
const Overlay = styled.div({
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
bottom: 0,
display: 'flex',
justifyContent: 'center',
left: 0,
position: 'absolute',
right: 0,
top: 0,
zIndex: 99999,
});
Overlay.displayName = 'ModalOverlay:Overlay';
export default class ModalOverlay extends Component<{
onClose: () => void;
children?: React.ReactNode;
}> {
ref?: HTMLElement | null;
setRef = (ref: HTMLElement | null) => {
this.ref = ref;
};
onClick = (e: React.MouseEvent) => {
if (e.target === this.ref) {
this.props.onClose();
}
};
render() {
const {props} = this;
return (
<Overlay ref={this.setRef} onClick={this.onClick}>
{props.children}
</Overlay>
);
}
}

View File

@@ -0,0 +1,36 @@
/**
* 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 {theme} from 'flipper-plugin';
export const multilineStyle = (props: {valid: boolean}) => ({
border: `1px solid ${
props.valid === false ? theme.errorColor : theme.dividerColor
}`,
borderRadius: 4,
font: 'inherit',
fontSize: '1em',
height: '28px',
lineHeight: '28px',
marginRight: 5,
backgroundColor: theme.backgroundDefault,
'&:disabled': {
backgroundColor: theme.backgroundWash,
cursor: 'not-allowed',
},
});
const MultiLineInput = styled.textarea<{valid?: boolean}>((props) => ({
...multilineStyle({valid: props.valid === undefined || props.valid}),
padding: '0 10px',
}));
MultiLineInput.displayName = 'MultiLineInput';
export default MultiLineInput;

View File

@@ -0,0 +1,430 @@
/**
* 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 '../../utils/geometry';
import styled from '@emotion/styled';
import {Component} from 'react';
import React from 'react';
export type OrderableOrder = Array<string>;
type OrderableOrientation = 'horizontal' | 'vertical';
type OrderableProps = {
items: {[key: string]: React.ReactNode};
orientation: OrderableOrientation;
onChange?: (order: OrderableOrder, key: string) => void;
order?: OrderableOrder | null | undefined;
className?: string;
reverse?: boolean;
altKey?: boolean;
moveDelay?: number;
dragOpacity?: number;
ignoreChildEvents?: boolean;
};
type OrderableState = {
order?: OrderableOrder | null | undefined;
movingOrder?: OrderableOrder | null | undefined;
};
type TabSizes = {
[key: string]: Rect;
};
const OrderableContainer = styled.div({
position: 'relative',
});
OrderableContainer.displayName = 'Orderable:OrderableContainer';
const OrderableItemContainer = styled.div<{
orientation: 'vertical' | 'horizontal';
}>((props) => ({
display: props.orientation === 'vertical' ? 'block' : 'inline-block',
}));
OrderableItemContainer.displayName = 'Orderable:OrderableItemContainer';
class OrderableItem extends Component<{
orientation: OrderableOrientation;
id: string;
children?: React.ReactNode;
addRef: (key: string, ref: HTMLElement | null) => void;
startMove: (KEY: string, event: React.MouseEvent) => void;
}> {
addRef = (ref: HTMLElement | null) => {
this.props.addRef(this.props.id, ref);
};
startMove = (event: React.MouseEvent) => {
this.props.startMove(this.props.id, event);
};
render() {
return (
<OrderableItemContainer
orientation={this.props.orientation}
key={this.props.id}
ref={this.addRef}
onMouseDown={this.startMove}>
{this.props.children}
</OrderableItemContainer>
);
}
}
export default class Orderable extends React.Component<
OrderableProps,
OrderableState
> {
constructor(props: OrderableProps, context: Object) {
super(props, context);
this.tabRefs = {};
this.state = {order: props.order};
this.setProps(props);
}
_mousemove: ((this: GlobalEventHandlers, ev: MouseEvent) => any) | undefined;
_mouseup: ((this: GlobalEventHandlers, ev: MouseEvent) => any) | undefined;
timer: any;
sizeKey: 'width' | 'height' = 'width';
offsetKey: 'left' | 'top' = 'left';
mouseKey: 'offsetX' | 'offsetY' = 'offsetX';
screenKey: 'screenX' | 'screenY' = 'screenX';
containerRef: HTMLElement | undefined | null;
tabRefs: {
[key: string]: HTMLElement | undefined | null;
};
static defaultProps = {
dragOpacity: 1,
moveDelay: 50,
};
setProps(props: OrderableProps) {
const {orientation} = props;
this.sizeKey = orientation === 'horizontal' ? 'width' : 'height';
this.offsetKey = orientation === 'horizontal' ? 'left' : 'top';
this.mouseKey = orientation === 'horizontal' ? 'offsetX' : 'offsetY';
this.screenKey = orientation === 'horizontal' ? 'screenX' : 'screenY';
}
shouldComponentUpdate() {
return !this.state.movingOrder;
}
UNSAFE_componentWillReceiveProps(nextProps: OrderableProps) {
this.setState({
order: nextProps.order,
});
this.setProps(nextProps);
}
startMove = (key: string, event: React.MouseEvent<Element, MouseEvent>) => {
if (this.props.altKey === true && event.altKey === false) {
return;
}
if (this.props.ignoreChildEvents === true) {
const tabRef = this.tabRefs[key];
if (
event.currentTarget !== tabRef &&
event.currentTarget.parentNode !== tabRef
) {
return;
}
}
this.reset();
event.persist();
const {moveDelay} = this.props;
if (moveDelay == null) {
this._startMove(key, event);
} else {
const cancel = () => {
clearTimeout(this.timer);
document.removeEventListener('mouseup', cancel);
};
document.addEventListener('mouseup', cancel);
this.timer = setTimeout(() => {
cancel();
this._startMove(key, event);
}, moveDelay);
}
};
_startMove(activeKey: string, event: React.MouseEvent) {
const clickOffset = event.nativeEvent[this.mouseKey];
// calculate offsets before we start moving element
const sizes: TabSizes = {};
for (const key in this.tabRefs) {
const elem = this.tabRefs[key];
if (elem) {
const rect: Rect = elem.getBoundingClientRect();
sizes[key] = {
height: rect.height,
left: elem.offsetLeft,
top: elem.offsetTop,
width: rect.width,
};
}
}
const {containerRef} = this;
if (containerRef) {
containerRef.style.height = `${containerRef.offsetHeight}px`;
containerRef.style.width = `${containerRef.offsetWidth}px`;
}
for (const key in this.tabRefs) {
const elem = this.tabRefs[key];
if (elem) {
const size = sizes[key];
elem.style.position = 'absolute';
elem.style.top = `${size.top}px`;
elem.style.left = `${size.left}px`;
elem.style.height = `${size.height}px`;
elem.style.width = `${size.width}px`;
}
}
document.addEventListener(
'mouseup',
(this._mouseup = () => {
this.stopMove(activeKey, sizes);
}),
{passive: true},
);
const screenClickPos = event.nativeEvent[this.screenKey];
document.addEventListener(
'mousemove',
(this._mousemove = (event: MouseEvent) => {
const goingOpposite = event[this.screenKey] < screenClickPos;
this.possibleMove(activeKey, goingOpposite, event, clickOffset, sizes);
}),
{passive: true},
);
}
possibleMove(
activeKey: string,
goingOpposite: boolean,
event: MouseEvent,
cursorOffset: number,
sizes: TabSizes,
) {
// update moving tab position
const {containerRef} = this;
const movingSize = sizes[activeKey];
const activeTab = this.tabRefs[activeKey];
if (containerRef) {
const containerRect: Rect = containerRef.getBoundingClientRect();
let newActivePos =
event[this.screenKey] - containerRect[this.offsetKey] - cursorOffset;
newActivePos = Math.max(-1, newActivePos);
newActivePos = Math.min(
newActivePos,
containerRect[this.sizeKey] - movingSize[this.sizeKey],
);
movingSize[this.offsetKey] = newActivePos;
if (activeTab) {
activeTab.style.setProperty(this.offsetKey, `${newActivePos}px`);
const {dragOpacity} = this.props;
if (dragOpacity != null && dragOpacity !== 1) {
activeTab.style.opacity = `${dragOpacity}`;
}
}
}
// figure out new order
const zipped: Array<[string, number]> = [];
for (const key in sizes) {
const rect = sizes[key];
let offset = rect[this.offsetKey];
let size = rect[this.sizeKey];
if (goingOpposite) {
// when dragging opposite add the size to the offset
if (key === activeKey) {
// calculate the active tab to be a quarter of the actual size so when dragging in the opposite
// direction, you need to cover 75% of the previous tab to trigger a movement
size *= 0.25;
}
offset += size;
} else if (key === activeKey) {
// if not dragging in the opposite direction and we're the active tab, require covering 25% of the
// next tab in roder to trigger a movement
offset += size * 0.75;
}
zipped.push([key, offset]);
}
// calculate ordering
const order = zipped
.sort(([, a], [, b]) => {
return Number(a > b);
})
.map(([key]) => key);
this.moveTabs(order, activeKey, sizes);
this.setState({movingOrder: order});
}
moveTabs(
order: OrderableOrder,
activeKey: string | null | undefined,
sizes: TabSizes,
) {
let offset = 0;
for (const key of order) {
const size = sizes[key];
const tab = this.tabRefs[key];
if (tab) {
let newZIndex = key === activeKey ? 2 : 1;
const prevZIndex = tab.style.zIndex;
if (prevZIndex) {
newZIndex += Number(prevZIndex);
}
tab.style.zIndex = String(newZIndex);
if (key === activeKey) {
tab.style.transition = 'opacity 100ms ease-in-out';
} else {
tab.style.transition = `${this.offsetKey} 300ms ease-in-out`;
tab.style.setProperty(this.offsetKey, `${offset}px`);
}
offset += size[this.sizeKey];
}
}
}
getMidpoint(rect: Rect) {
return rect[this.offsetKey] + rect[this.sizeKey] / 2;
}
stopMove(activeKey: string, sizes: TabSizes) {
const {movingOrder} = this.state;
const {onChange} = this.props;
if (onChange && movingOrder) {
const activeTab = this.tabRefs[activeKey];
if (activeTab) {
activeTab.style.opacity = '';
const transitionend = () => {
activeTab.removeEventListener('transitionend', transitionend);
this.reset();
};
activeTab.addEventListener('transitionend', transitionend);
}
this.resetListeners();
this.moveTabs(movingOrder, null, sizes);
onChange(movingOrder, activeKey);
} else {
this.reset();
}
this.setState({movingOrder: null});
}
resetListeners() {
clearTimeout(this.timer);
const {_mousemove, _mouseup} = this;
if (_mouseup) {
document.removeEventListener('mouseup', _mouseup);
}
if (_mousemove) {
document.removeEventListener('mousemove', _mousemove);
}
}
reset() {
this.resetListeners();
const {containerRef} = this;
if (containerRef) {
containerRef.removeAttribute('style');
}
for (const key in this.tabRefs) {
const elem = this.tabRefs[key];
if (elem) {
elem.removeAttribute('style');
}
}
}
componentWillUnmount() {
this.reset();
}
addRef = (key: string, elem: HTMLElement | null) => {
this.tabRefs[key] = elem;
};
setContainerRef = (ref: HTMLElement | null) => {
this.containerRef = ref;
};
render() {
const {items} = this.props;
// calculate order of elements
let {order} = this.state;
if (!order) {
order = Object.keys(items);
}
for (const key in items) {
if (order.indexOf(key) < 0) {
if (this.props.reverse === true) {
order.unshift(key);
} else {
order.push(key);
}
}
}
return (
<OrderableContainer
className={this.props.className}
ref={this.setContainerRef}>
{order.map((key) => {
const item = items[key];
if (item) {
return (
<OrderableItem
orientation={this.props.orientation}
key={key}
id={key}
addRef={this.addRef}
startMove={this.startMove}>
{item}
</OrderableItem>
);
} else {
return null;
}
})}
</OrderableContainer>
);
}
}

View File

@@ -0,0 +1,188 @@
/**
* 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, {CSSProperties} from 'react';
import styled from '@emotion/styled';
import FlexColumn from './FlexColumn';
import FlexBox from './FlexBox';
import {colors} from './colors';
import Glyph from './Glyph';
import {theme} from 'flipper-plugin';
const BORDER = `1px solid ${theme.dividerColor}`;
const Chevron = styled(Glyph)({
marginRight: 4,
marginLeft: -2,
marginBottom: 1,
});
Chevron.displayName = 'Panel:Chevron';
/**
* A Panel component.
*/
export default class Panel extends React.Component<
{
/**
* Class name to customise styling.
*/
className?: string;
/**
* Whether this panel is floating from the rest of the UI. ie. if it has
* margin and a border.
*/
floating?: boolean;
/**
* Whether the panel takes up all the space it can. Equivalent to the following CSS:
*
* height: 100%;
* width: 100%;
*/
grow?: boolean;
/**
* Heading for this panel. If this is anything other than a string then no
* padding is applied to the heading.
*/
heading: React.ReactNode;
/**
* Contents of the panel.
*/
children?: React.ReactNode;
/**
* Whether the panel header and body have padding.
*/
padded?: boolean;
/**
* Whether the panel can be collapsed. Defaults to true
*/
collapsable: boolean;
/**
* Initial state for panel if it is collapsable
*/
collapsed?: boolean;
/**
* Heading for this panel. If this is anything other than a string then no
* padding is applied to the heading.
*/
accessory?: React.ReactNode;
style?: CSSProperties;
},
{
collapsed: boolean;
}
> {
static defaultProps: {
floating: boolean;
grow: boolean;
collapsable: boolean;
} = {
grow: false,
floating: true,
collapsable: true,
};
static PanelContainer = styled(FlexColumn)<{
floating?: boolean;
collapsed?: boolean;
grow?: boolean;
}>((props) => ({
flexShrink: 0,
flexGrow: props.grow ? 1 : undefined,
padding: props.floating ? 10 : 0,
borderBottom: props.collapsed ? 'none' : BORDER,
}));
static PanelHeader = styled(FlexBox)<{floating?: boolean; padded?: boolean}>(
(props) => ({
userSelect: 'none',
color: theme.textColorPrimary,
backgroundColor: theme.backgroundWash,
border: props.floating ? BORDER : 'none',
borderBottom: BORDER,
borderTopLeftRadius: 2,
borderTopRightRadius: 2,
justifyContent: 'space-between',
lineHeight: '27px',
fontWeight: 500,
flexShrink: 0,
padding: props.padded ? '0 10px' : 0,
'&:not(:first-child)': {
borderTop: BORDER,
},
}),
);
static PanelBody = styled(FlexColumn)<{floating?: boolean; padded?: boolean}>(
(props) => ({
backgroundColor: theme.backgroundDefault,
border: props.floating ? BORDER : 'none',
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
borderTop: 'none',
flexGrow: 1,
padding: props.padded ? 10 : 0,
overflow: 'visible',
}),
);
state = {
collapsed: this.props.collapsed == null ? false : this.props.collapsed,
};
onClick = () => this.setState({collapsed: !this.state.collapsed});
render() {
const {
padded,
children,
className,
grow,
floating,
heading,
collapsable,
accessory,
style,
} = this.props;
const {collapsed} = this.state;
return (
<Panel.PanelContainer
className={className}
floating={floating}
grow={grow}
collapsed={collapsed}
style={style}>
<Panel.PanelHeader
floating={floating}
padded={padded || typeof heading === 'string'}
onClick={this.onClick}>
<span>
{collapsable && (
<Chevron
color={colors.macOSTitleBarIcon}
name={collapsed ? 'triangle-right' : 'triangle-down'}
size={12}
/>
)}
{heading}
</span>
{accessory}
</Panel.PanelHeader>
{children == null || (collapsable && collapsed) ? null : (
<Panel.PanelBody
scrollable
grow={grow}
padded={padded == null ? true : padded}
floating={floating}>
{children}
</Panel.PanelBody>
)}
</Panel.PanelContainer>
);
}
}

View File

@@ -0,0 +1,48 @@
/**
* 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 {PureComponent} from 'react';
import styled from '@emotion/styled';
import React from 'react';
type RadioProps = {
/** Whether the radio button is checked. */
checked: boolean;
/** Called when a state change is triggered */
onChange: (selected: boolean) => void;
disabled?: boolean;
};
const RadioboxContainer = styled.input({
display: 'inline-block',
marginRight: 5,
verticalAlign: 'middle',
});
RadioboxContainer.displayName = 'Radiobox:RadioboxContainer';
/**
* A radio button to toggle UI state
* @deprecated use Radio from 'antd'
*/
export default class Radio extends PureComponent<RadioProps> {
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.props.onChange(e.target.checked);
};
render() {
return (
<RadioboxContainer
type="radio"
checked={this.props.checked}
onChange={this.onChange}
disabled={this.props.disabled}
/>
);
}
}

View File

@@ -0,0 +1,45 @@
/**
* 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 {isLoggedIn} from '../../fb-stubs/user';
import {Layout, useValue} from 'flipper-plugin';
import React from 'react';
import config from '../../fb-stubs/config';
import {Alert} from 'antd';
import {LoginOutlined} from '@ant-design/icons';
export const RequireLogin: React.FC<{}> = ({children}) => {
const loggedIn = useValue(isLoggedIn());
if (!config.isFBBuild) {
return (
<Layout.Container pad>
<Alert
type="error"
message="This feature is only available in the Facebook version of Flipper"
/>
</Layout.Container>
);
}
if (!loggedIn) {
return (
<Layout.Container pad>
<Alert
type="error"
message={
<>
You are currently not logged in. Please log in using the{' '}
<LoginOutlined /> button to use this feature.
</>
}
/>
</Layout.Container>
);
}
return <>{children}</>;
};

View File

@@ -0,0 +1,45 @@
/**
* 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 from 'react';
import styled from '@emotion/styled';
import {theme, Layout} from 'flipper-plugin';
import {Typography} from 'antd';
const Divider = styled.hr({
margin: '16px -20px 20px -20px',
border: 'none',
borderTop: `1px solid ${theme.dividerColor}`,
});
Divider.displayName = 'RoundedSection:Divider';
const Container = styled.div({
background: theme.backgroundDefault,
borderRadius: theme.space.medium,
boxShadow: `0 1px 3px ${theme.dividerColor}`,
marginBottom: theme.space.large,
width: '100%',
padding: theme.space.large,
});
Container.displayName = 'RoundedSection:Container';
/**
* Section with a title, dropshadow, rounded border and white backgorund.
*
* Recommended to be used inside a CenteredView
*/
const RoundedSection: React.FC<{title: string}> = ({title, children}) => (
<Container>
<Typography.Title level={3}>{title}</Typography.Title>
<Divider />
<Layout.Container>{children}</Layout.Container>
</Container>
);
export default RoundedSection;

View File

@@ -0,0 +1,27 @@
/**
* 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 from 'react';
import styled from '@emotion/styled';
import {Property} from 'csstype';
type Props = {children: React.ReactNode; background?: Property.Background<any>};
/**
* @deprecated use Layout.ScrollContainer from 'flipper-plugin'
*/
const Scrollable = styled.div<Props>(({background}) => ({
width: '100%',
height: '100%',
overflow: 'auto',
background,
}));
Scrollable.displayName = 'Scrollable';
export default Scrollable;

View File

@@ -0,0 +1,106 @@
/**
* 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 {Component, CSSProperties} from 'react';
import Text from './Text';
import styled from '@emotion/styled';
import React from 'react';
import {theme} from 'flipper-plugin';
const Label = styled.label({
display: 'flex',
alignItems: 'center',
});
Label.displayName = 'Select:Label';
const LabelText = styled(Text)({
fontWeight: 500,
marginRight: 5,
});
LabelText.displayName = 'Select:LabelText';
const SelectMenu = styled.select<{grow?: boolean}>((props) => ({
flexGrow: props.grow ? 1 : 0,
background: theme.backgroundDefault,
border: `1px solid ${theme.dividerColor}`,
}));
SelectMenu.displayName = 'Select:SelectMenu';
/**
* Dropdown to select from a list of options
* @deprecated use Select from antd instead: https://ant.design/components/select/
*/
export default class Select extends Component<{
/** Additional className added to the element */
className?: string;
/** The list of options to display */
options: {
[key: string]: string;
};
/** DEPRECATED: Callback when the selected value changes. The callback is called with the displayed value. */
onChange?: (value: string) => void;
/** Callback when the selected value changes. The callback is called with the key for the displayed value */
onChangeWithKey?: (key: string) => void;
/** Selected key */
selected?: string | null | undefined;
/** Label shown next to the dropdown */
label?: string;
/** Select box should take all available space */
grow?: boolean;
/** Whether the user can interact with the select and change the selcted option */
disabled?: boolean;
style?: CSSProperties;
}> {
selectID: string = Math.random().toString(36);
onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
if (this.props.onChangeWithKey) {
this.props.onChangeWithKey(event.target.value);
}
if (this.props.onChange) {
this.props.onChange(this.props.options[event.target.value]);
}
};
render() {
const {className, options, selected, label, grow, disabled, style} =
this.props;
let select = (
<SelectMenu
grow={grow}
id={this.selectID}
onChange={this.onChange}
className={className}
disabled={disabled}
value={selected || ''}
style={style}>
{Object.keys(options).map((key, index) => (
<option value={key} key={index}>
{options[key]}
</option>
))}
</SelectMenu>
);
if (label) {
select = (
<Label htmlFor={this.selectID}>
<LabelText>{label}</LabelText>
{select}
</Label>
);
}
return select;
}
}

View File

@@ -0,0 +1,205 @@
/**
* 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 {theme, _Interactive, _InteractiveProps} from 'flipper-plugin';
import FlexColumn from './FlexColumn';
import {Component} from 'react';
import styled from '@emotion/styled';
import {Property} from 'csstype';
import React from 'react';
const SidebarInteractiveContainer = styled(_Interactive)<_InteractiveProps>({
flex: 'none',
});
SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer';
type SidebarPosition = 'left' | 'top' | 'right' | 'bottom';
const borderStyle = '1px solid ' + theme.dividerColor;
const SidebarContainer = styled(FlexColumn)<{
position: 'right' | 'top' | 'left' | 'bottom';
backgroundColor?: Property.BackgroundClip;
overflow?: boolean;
unstyled?: boolean;
}>((props) => ({
...(props.unstyled
? undefined
: {
backgroundColor: props.backgroundColor || theme.backgroundDefault,
borderLeft: props.position === 'right' ? borderStyle : 'none',
borderTop: props.position === 'bottom' ? borderStyle : 'none',
borderRight: props.position === 'left' ? borderStyle : 'none',
borderBottom: props.position === 'top' ? borderStyle : 'none',
}),
height: '100%',
overflowX: 'hidden',
overflowY: 'auto',
textOverflow: props.overflow ? 'ellipsis' : 'auto',
whiteSpace: props.overflow ? 'nowrap' : 'normal',
}));
SidebarContainer.displayName = 'Sidebar:SidebarContainer';
type SidebarProps = {
/**
* Position of the sidebar.
*/
position: SidebarPosition;
/**
* Default width of the sidebar. Only used for left/right sidebars.
*/
width?: number;
/**
* Minimum sidebar width. Only used for left/right sidebars.
*/
minWidth?: number;
/**
* Maximum sidebar width. Only used for left/right sidebars.
*/
maxWidth?: number;
/**
* Default height of the sidebar.
*/
height?: number;
/**
* Minimum sidebar height. Only used for top/bottom sidebars.
*/
minHeight?: number;
/**
* Maximum sidebar height. Only used for top/bottom sidebars.
*/
maxHeight?: number;
/**
* Background color.
*/
backgroundColor?: Property.BackgroundColor;
/**
* Callback when the sidebar size ahs changed.
*/
onResize?: (width: number, height: number) => void;
/**
* Contents of the sidebar.
*/
children?: React.ReactNode;
/**
* Class name to customise styling.
*/
className?: string;
};
type SidebarState = {
width?: Property.Width<number>;
height?: Property.Height<number>;
userChange: boolean;
};
/**
* A resizable sidebar.
* @deprecated use Layout.Top / Right / Bottom / Left from flipper-plugin instead
*/
export default class Sidebar extends Component<SidebarProps, SidebarState> {
constructor(props: SidebarProps, context: Object) {
super(props, context);
this.state = {
userChange: false,
width: props.width,
height: props.height,
};
}
static defaultProps = {
position: 'left',
};
static getDerivedStateFromProps(
nextProps: SidebarProps,
state: SidebarState,
) {
if (!state.userChange) {
return {width: nextProps.width, height: nextProps.height};
}
return null;
}
onResize = (width: number, height: number) => {
const {onResize} = this.props;
if (onResize) {
onResize(width, height);
} else {
this.setState({userChange: true, width, height});
}
};
render() {
const {backgroundColor, onResize, position, children} = this.props;
let height: number | undefined;
let minHeight: number | undefined;
let maxHeight: number | undefined;
let width: number | undefined;
let minWidth: number | undefined;
let maxWidth: number | undefined;
const resizable: {[key: string]: boolean} = {};
if (position === 'left') {
resizable.right = true;
({width, minWidth, maxWidth} = this.props);
} else if (position === 'top') {
resizable.bottom = true;
({height, minHeight, maxHeight} = this.props);
} else if (position === 'right') {
resizable.left = true;
({width, minWidth, maxWidth} = this.props);
} else if (position === 'bottom') {
resizable.top = true;
({height, minHeight, maxHeight} = this.props);
}
const horizontal = position === 'left' || position === 'right';
const gutterWidth = 0;
if (horizontal) {
width = width == null ? 200 : width;
minWidth = (minWidth == null ? 100 : minWidth) + gutterWidth;
maxWidth = maxWidth == null ? 600 : maxWidth;
} else {
height = height == null ? 200 : height;
minHeight = minHeight == null ? 100 : minHeight;
maxHeight = maxHeight == null ? 600 : maxHeight;
}
return (
<SidebarInteractiveContainer
className={this.props.className}
minWidth={minWidth}
maxWidth={maxWidth}
width={
horizontal
? !children
? gutterWidth
: onResize
? width
: this.state.width
: undefined
}
minHeight={minHeight}
maxHeight={maxHeight}
height={
!horizontal ? (onResize ? height : this.state.height) : undefined
}
resizable={resizable}
onResize={this.onResize}>
<SidebarContainer position={position} backgroundColor={backgroundColor}>
{children}
</SidebarContainer>
</SidebarInteractiveContainer>
);
}
}

View File

@@ -0,0 +1,26 @@
/**
* 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 {colors} from './colors';
import Text from './Text';
/**
* Subtle text that should not draw attention
*/
const SmallText = styled(Text)<{center?: boolean}>((props) => ({
color: colors.light20,
size: 10,
fontStyle: 'italic',
textAlign: props.center ? 'center' : undefined,
width: '100%',
}));
SmallText.displayName = 'SmallText';
export default SmallText;

View File

@@ -0,0 +1,202 @@
/**
* 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 {Component} from 'react';
import Text from './Text';
import {colors} from './colors';
import ManagedTable from './table/ManagedTable';
import FlexRow from './FlexRow';
import Glyph from './Glyph';
import styled from '@emotion/styled';
import React from 'react';
import {Property} from 'csstype';
import {
TableBodyRow,
TableColumnSizes,
TableColumns,
TableBodyColumn,
} from './table/types';
const Padder = styled.div<{
padded?: boolean;
backgroundColor?: Property.BackgroundColor;
}>(({padded, backgroundColor}) => ({
padding: padded ? 10 : 0,
backgroundColor,
}));
Padder.displayName = 'StackTrace:Padder';
const Container = styled.div<{isCrash?: boolean; padded?: boolean}>(
({isCrash, padded}) => ({
backgroundColor: isCrash ? colors.redTint : 'transprent',
border: padded
? `1px solid ${isCrash ? colors.red : colors.light15}`
: 'none',
borderRadius: padded ? 5 : 0,
overflow: 'hidden',
}),
);
Container.displayName = 'StackTrace:Container';
const Title = styled(FlexRow)<{isCrash?: boolean}>(({isCrash}) => ({
color: isCrash ? colors.red : 'inherit',
padding: 8,
alignItems: 'center',
minHeight: 32,
}));
Title.displayName = 'StackTrace:Title';
const Reason = styled(Text)<{isCrash?: boolean}>(({isCrash}) => ({
color: isCrash ? colors.red : colors.light80,
fontWeight: 'bold',
fontSize: 13,
}));
Reason.displayName = 'StackTrace:Reason';
const Line = styled(Text)<{isCrash?: boolean; isBold?: boolean}>(
({isCrash, isBold}) => ({
color: isCrash ? colors.red : colors.light80,
fontWeight: isBold ? 'bold' : 'normal',
}),
);
Line.displayName = 'StackTrace:Line';
const Icon = styled(Glyph)({marginRight: 5});
Icon.displayName = 'StackTrace:Icon';
const COLUMNS = {
lineNumber: 40,
address: 150,
library: 150,
message: 'flex',
caller: 200,
};
type Child = {
isBold?: boolean;
library?: string | null | undefined;
address?: string | null | undefined;
caller?: string | null | undefined;
lineNumber?: string | null | undefined;
message?: string | null | undefined;
};
/**
* Display a stack trace
*/
export default class StackTrace extends Component<{
children: Child[];
/**
* Reason for the crash, displayed above the trace
*/
reason?: string;
/**
* Does the trace show a crash
*/
isCrash?: boolean;
/**
* Display the stack trace in a padded container
*/
padded?: boolean;
/**
* Background color of the stack trace
*/
backgroundColor?: string;
}> {
render() {
const {children} = this.props;
if (!children || children.length === 0) {
return null;
}
const columns = (
Object.keys(children[0]) as Array<keyof Child>
).reduce<TableColumns>((acc, cv) => {
if (cv !== 'isBold') {
acc[cv] = {
value: cv,
};
}
return acc;
}, {});
const columnOrder = Object.keys(COLUMNS).map((key) => ({
key,
visible: Boolean(columns[key]),
}));
const columnSizes = (
Object.keys(COLUMNS) as Array<keyof typeof COLUMNS>
).reduce<TableColumnSizes>((acc, cv: keyof typeof COLUMNS) => {
acc[cv] =
COLUMNS[cv] === 'flex'
? 'flex'
: children.reduce(
(acc, line) =>
Math.max(acc, line[cv] ? line[cv]!.length : 0 || 0),
0,
) *
8 +
16; // approx 8px per character + 16px padding left/right
return acc;
}, {});
const rows: TableBodyRow[] = children.map((l, i) => ({
key: String(i),
columns: (Object.keys(columns) as Array<keyof Child>).reduce<{
[key: string]: TableBodyColumn;
}>((acc, cv) => {
acc[cv] = {
align: cv === 'lineNumber' ? 'right' : 'left',
value: (
<Line code isCrash={this.props.isCrash} bold={l.isBold || false}>
{String(l[cv])}
</Line>
),
};
return acc;
}, {}),
}));
return (
<Padder
padded={this.props.padded}
backgroundColor={this.props.backgroundColor}>
<Container isCrash={this.props.isCrash} padded={this.props.padded}>
{this.props.reason && (
<Title isCrash={this.props.isCrash}>
{this.props.isCrash && (
<Icon
name="stop"
variant="filled"
size={16}
color={colors.red}
/>
)}
<Reason isCrash={this.props.isCrash} code>
{this.props.reason}
</Reason>
</Title>
)}
<ManagedTable
columns={columns}
rows={rows}
hideHeader
autoHeight
zebra={false}
columnOrder={columnOrder}
columnSizes={columnSizes}
highlightableRows={false}
/>
</Container>
</Padder>
);
}
}

View File

@@ -0,0 +1,63 @@
/**
* 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, {useState, useCallback} from 'react';
import {colors} from './colors';
import Glyph from './Glyph';
import styled from '@emotion/styled';
const DownscaledGlyph = styled(Glyph)({
maskSize: '12px 12px',
WebkitMaskSize: '12px 12px',
height: 12,
width: 12,
});
DownscaledGlyph.displayName = 'StarButton:DownscaledGlyph';
export function StarButton({
starred,
onStar,
}: {
starred: boolean;
onStar: () => void;
}) {
const [hovered, setHovered] = useState(false);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onStar();
},
[onStar],
);
const handleMouseEnter = useCallback(setHovered.bind(null, true), []);
const handleMouseLeave = useCallback(setHovered.bind(null, false), []);
return (
<button
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
padding: 0,
paddingLeft: 4,
flex: 0,
}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<DownscaledGlyph
size={
16 /* the icons used below are not available in smaller sizes :-/ */
}
name={hovered ? (starred ? 'star-slash' : 'life-event-major') : 'star'}
color={hovered ? colors.lemonDark1 : colors.macOSTitleBarIconBlur}
variant={hovered || starred ? 'filled' : 'outline'}
/>
</button>
);
}

View File

@@ -0,0 +1,36 @@
/**
* 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 {colors} from './colors';
import {Property} from 'csstype';
type Props = {
statusColor: Property.BackgroundColor;
diameter?: Property.Height<number>;
title?: string;
};
const StatusIndicator = styled.div<Props>(
({statusColor, diameter = 10, title}) => ({
alignSelf: 'center',
backgroundColor: statusColor,
border: `1px solid ${colors.blackAlpha30}`,
borderRadius: '50%',
display: 'inline-block',
flexShrink: 0,
height: diameter,
title,
width: diameter,
}),
);
StatusIndicator.displayName = 'StatusIndicator';
export default StatusIndicator;

View File

@@ -0,0 +1,48 @@
/**
* 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 {Property} from 'csstype';
export type Props = {
/**
* Label of this tab to show in the tab list.
*/
label: React.ReactNode;
/**
* Whether this tab is closable.
*/
closable?: boolean;
/**
* Whether this tab is hidden. Useful for when you want a tab to be
* inaccessible via the user but you want to manually set the `active` props
* yourself.
*/
hidden?: boolean;
/**
* Whether this tab should always be included in the DOM and have its
* visibility toggled.
*/
persist?: boolean;
/**
* Callback for when tab is closed.
*/
onClose?: () => void;
/**
* Contents of this tab.
*/
children?: React.ReactNode;
width?: Property.Width<number>;
};
/**
* @deprecated use Tab from flipper-plugin
*/
export default function Tab(_props: Props): JSX.Element {
throw new Error("don't render me");
}

View File

@@ -0,0 +1,336 @@
/**
* 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 FlexColumn from './FlexColumn';
import styled from '@emotion/styled';
import Orderable from './Orderable';
import FlexRow from './FlexRow';
import {colors} from './colors';
import Tab, {Props as TabProps} from './Tab';
import {Property} from 'csstype';
import React, {useContext} from 'react';
import {TabsContext} from './TabsContainer';
import {theme, _wrapInteractionHandler} from 'flipper-plugin';
const TabList = styled(FlexRow)({
justifyContent: 'center',
alignItems: 'stretch',
});
TabList.displayName = 'Tabs:TabList';
const TabListItem = styled.div<{
active?: boolean;
width?: Property.Width<number>;
container?: boolean;
}>((props) => ({
userSelect: 'none',
background: props.container
? props.active
? 'linear-gradient(to bottom, #67a6f7 0%, #0072FA 100%)'
: `linear-gradient(to bottom, white 0%,${colors.macOSTitleBarButtonBackgroundBlur} 100%)`
: props.active
? theme.primaryColor
: theme.backgroundWash,
borderBottom: props.container
? '1px solid #B8B8B8'
: `1px solid ${theme.dividerColor}`,
boxShadow:
props.active && props.container
? 'inset 0px 0px 3px rgba(0,0,0,0.25)'
: 'none',
color: props.container
? props.active
? colors.white
: colors.dark80
: props.active
? colors.white
: theme.textColorPrimary,
flex: props.container ? 'unset' : 1,
top: props.container ? -11 : 0,
fontWeight: 500,
fontSize: 13,
lineHeight: props.container ? '22px' : '28px',
overflow: 'hidden',
padding: '0 10px',
position: 'relative',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
'&:first-child': {
borderTopLeftRadius: props.container ? 3 : 0,
borderBottomLeftRadius: props.container ? 3 : 0,
},
'&:last-child': {
borderTopRightRadius: props.container ? 3 : 0,
borderBottomRightRadius: props.container ? 3 : 0,
},
'&:hover': {
backgroundColor: theme.backgroundTransparentHover,
},
}));
TabListItem.displayName = 'Tabs:TabListItem';
const TabListAddItem = styled(TabListItem)({
borderRight: 'none',
flex: 0,
flexGrow: 0,
fontWeight: 'bold',
});
TabListAddItem.displayName = 'Tabs:TabListAddItem';
const CloseButton = styled.div({
color: theme.textColorPrimary,
float: 'right',
fontSize: 10,
fontWeight: 'bold',
textAlign: 'center',
marginLeft: 6,
marginTop: 6,
width: 16,
height: 16,
lineHeight: '16px',
borderRadius: '50%',
'&:hover': {
backgroundColor: colors.cherry,
color: '#fff',
},
});
CloseButton.displayName = 'Tabs:CloseButton';
const OrderableContainer = styled.div({
display: 'inline-block',
});
OrderableContainer.displayName = 'Tabs:OrderableContainer';
const TabContent = styled.div({
height: '100%',
overflow: 'auto',
width: '100%',
display: 'flex',
});
TabContent.displayName = 'Tabs:TabContent';
/**
* A Tabs component.
* @deprecated use Tabs from flipper-plugin
*/
export default function Tabs(props: {
/**
* Callback for when the active tab has changed.
*/
onActive?: (key: string | null | undefined) => void;
/**
* The key of the default active tab.
*/
defaultActive?: string;
/**
* The key of the currently active tab.
*/
active?: string | null | undefined;
/**
* Tab elements.
*/
children?: React.ReactElement<TabProps>[] | React.ReactElement<TabProps>;
/**
* Whether the tabs can be reordered by the user.
*/
orderable?: boolean;
/**
* Callback when the tab order changes.
*/
onOrder?: (order: Array<string>) => void;
/**
* Order of tabs.
*/
order?: Array<string>;
/**
* Whether to include the contents of every tab in the DOM and just toggle
* its visibility.
*/
persist?: boolean;
/**
* Whether to include a button to create additional items.
*/
newable?: boolean;
/**
* Callback for when the new button is clicked.
*/
onNew?: () => void;
/**
* Elements to insert before all tabs in the tab list.
*/
before?: Array<React.ReactNode>;
/**
* Elements to insert after all tabs in the tab list.
*/
after?: Array<React.ReactNode>;
/**
* By default tabs are rendered in mac-style tabs, with a negative offset.
* By setting classic mode the classic style is rendered.
*/
classic?: boolean;
}) {
let tabsContainer = useContext(TabsContext);
const scope = useContext((global as any).FlipperTrackingScopeContext);
if (props.classic === true) {
tabsContainer = false;
}
const {onActive} = props;
const active: string | undefined =
props.active == null ? props.defaultActive : props.active;
// array of other components that aren't tabs
const before = props.before || [];
const after = props.after || [];
//
const tabs: {
[key: string]: React.ReactNode;
} = {};
// a list of keys
const keys = props.order ? props.order.slice() : [];
const tabContents: React.ReactNode[] = [];
const tabSiblings: React.ReactNode[] = [];
function add(comps: React.ReactElement | React.ReactElement[]) {
const compsArray: React.ReactElement<TabProps>[] = Array.isArray(comps)
? comps
: [comps];
for (const comp of compsArray) {
if (Array.isArray(comp)) {
add(comp);
continue;
}
if (!comp) {
continue;
}
if (comp.type !== Tab) {
// if element isn't a tab then just push it into the tab list
tabSiblings.push(comp);
continue;
}
const {children, closable, label, onClose, width} = comp.props;
const key = comp.key == null ? label : comp.key;
if (typeof key !== 'string') {
throw new Error('tab needs a string key or a label');
}
if (!keys.includes(key)) {
keys.push(key);
}
const isActive: boolean = active === key;
if (isActive || props.persist === true || comp.props.persist === true) {
tabContents.push(
<TabContent key={key} hidden={!isActive}>
{children}
</TabContent>,
);
}
// this tab has been hidden from the tab bar but can still be selected if its key is active
if (comp.props.hidden) {
continue;
}
let closeButton: HTMLDivElement | undefined | null;
tabs[key] = (
<TabListItem
key={key}
width={width}
active={isActive}
container={tabsContainer}
onMouseDown={
!isActive && onActive
? _wrapInteractionHandler(
(event: React.MouseEvent<HTMLDivElement>) => {
if (event.target !== closeButton) {
onActive(key);
}
},
'Tabs',
'onTabClick',
scope as any,
'tab:' + key + ':' + comp.props.label,
)
: undefined
}>
{comp.props.label}
{closable && (
<CloseButton // eslint-disable-next-line react/jsx-no-bind
ref={(ref) => (closeButton = ref)} // eslint-disable-next-line react/jsx-no-bind
onMouseDown={() => {
if (isActive && onActive) {
const index = keys.indexOf(key);
const newActive = keys[index + 1] || keys[index - 1] || null;
onActive(newActive);
}
if (onClose) {
onClose();
}
}}>
X
</CloseButton>
)}
</TabListItem>
);
}
}
if (props.children) {
add(props.children);
}
let tabList: React.ReactNode;
if (props.orderable === true) {
tabList = (
<OrderableContainer key="orderable-list">
<Orderable
orientation="horizontal"
items={tabs}
onChange={props.onOrder}
order={keys}
/>
</OrderableContainer>
);
} else {
tabList = [];
for (const key in tabs) {
(tabList as Array<React.ReactNode>).push(tabs[key]);
}
}
if (props.newable === true) {
after.push(
<TabListAddItem key={keys.length} onMouseDown={props.onNew}>
+
</TabListAddItem>,
);
}
return (
<FlexColumn grow>
<TabList>
{before}
{tabList}
{after}
</TabList>
{tabContents}
{tabSiblings}
</FlexColumn>
);
}

View File

@@ -0,0 +1,32 @@
/**
* 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 from 'react';
import styled from '@emotion/styled';
const Container = styled.div({
backgroundColor: '#E3E3E3',
borderRadius: 4,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.1)',
padding: 10,
paddingTop: 0,
marginTop: 11,
marginBottom: 10,
});
Container.displayName = 'TabsContainer:Container';
export const TabsContext = React.createContext(true);
export default function TabsContainer(props: {children: any}) {
return (
<Container>
<TabsContext.Provider value>{props.children}</TabsContext.Provider>
</Container>
);
}

View File

@@ -0,0 +1,56 @@
/**
* 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 {Property} from 'csstype';
/**
* A Text component.
*/
const Text = styled.span<{
color?: Property.Color;
bold?: boolean;
italic?: boolean;
underline?: boolean;
align?: Property.TextAlign;
size?: Property.FontSize<number>;
code?: boolean;
family?: Property.FontFamily;
selectable?: boolean;
wordWrap?: Property.WordWrap;
whiteSpace?: Property.WhiteSpace;
cursor?: Property.Cursor;
}>((props) => ({
color: props.color ? props.color : 'inherit',
cursor: props.cursor ? props.cursor : 'auto',
display: 'inline',
fontWeight: props.bold ? 'bold' : 'inherit',
fontStyle: props.italic ? 'italic' : 'normal',
textAlign: props.align || 'left',
fontSize: props.size == null && props.code ? 12 : props.size,
textDecoration: props.underline ? 'underline' : 'initial',
fontFamily: props.code
? 'SF Mono, Monaco, Andale Mono, monospace'
: props.family,
overflow: props.code ? 'auto' : 'visible',
userSelect:
props.selectable === false
? 'none'
: props.selectable === true
? 'text'
: undefined,
wordWrap: props.code ? 'break-word' : props.wordWrap,
whiteSpace:
props.code && typeof props.whiteSpace === 'undefined'
? 'pre'
: props.whiteSpace,
}));
Text.displayName = 'Text';
export default Text;

View File

@@ -0,0 +1,29 @@
/**
* 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 {inputStyle} from './Input';
const Textarea = styled.textarea<{
compact?: boolean;
readOnly?: boolean;
valid?: boolean;
}>(({compact, readOnly, valid}) => ({
...inputStyle({
compact: compact || false,
readOnly: readOnly || false,
valid: valid !== false,
}),
lineHeight: 'normal',
padding: compact ? '5px' : '8px',
resize: 'none',
}));
Textarea.displayName = 'Textarea';
export default Textarea;

View File

@@ -0,0 +1,109 @@
/**
* 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, {useState, useRef, useEffect} from 'react';
import styled from '@emotion/styled';
import {colors} from './colors';
import Text from './Text';
import FlexRow from './FlexRow';
export const StyledButton = styled.div<{toggled: boolean; large: boolean}>(
({large, toggled}) => ({
width: large ? 60 : 30,
height: large ? 32 : 16,
background: toggled ? colors.green : colors.gray,
display: 'block',
borderRadius: '100px',
position: 'relative',
marginLeft: large ? 0 : 15, // margins in components should die :-/
flexShrink: 0,
'&::after': {
content: '""',
position: 'absolute',
top: large ? 6 : 3,
left: large ? (toggled ? 34 : 6) : toggled ? 18 : 3,
width: large ? 20 : 10,
height: large ? 20 : 10,
background: 'white',
borderRadius: '100px',
transition: 'all cubic-bezier(0.3, 1.5, 0.7, 1) 0.3s',
},
}),
);
StyledButton.displayName = 'ToggleSwitch:StyledButton';
const Container = styled(FlexRow)({
alignItems: 'center',
cursor: 'pointer',
});
Container.displayName = 'ToggleSwitch:Container';
const Label = styled(Text)({
marginLeft: 7,
marginRight: 7,
lineHeight: 1.3,
});
Label.displayName = 'ToggleSwitch:Label';
type Props = {
/**
* onClick handler.
*/
onClick?: (event: React.MouseEvent) => void;
/**
* whether the button is toggled
*/
toggled?: boolean;
className?: string;
label?: string;
tooltip?: string;
large?: boolean;
};
/**
* Toggle Button.
*
* **Usage**
*
* ```jsx
* import {ToggleButton} from '../ui';
* <ToggleButton onClick={handler} toggled={boolean}/>
* ```
*/
export default function ToggleButton(props: Props) {
const unmounted = useRef(false);
const [switching, setSwitching] = useState(false);
useEffect(
() => () => {
// suppress switching after unmount
unmounted.current = true;
},
[],
);
return (
<Container
onClick={(e) => {
setSwitching(true);
setTimeout(() => {
props?.onClick?.(e);
if (unmounted.current === false) {
setSwitching(false);
}
}, 300);
}}
title={props.tooltip}>
<StyledButton
large={!!props.large}
className={props.className}
toggled={switching ? !props.toggled : !!props.toggled}
/>
{props.label && <Label>{props.label}</Label>}
</Container>
);
}

View File

@@ -0,0 +1,19 @@
/**
* 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 FlexBox from './FlexBox';
import styled from '@emotion/styled';
/**
* Deprecated, set 'gap' on the parent container instead
*/
export const Spacer = styled(FlexBox)({
flexGrow: 1,
});
Spacer.displayName = 'Spacer';

View File

@@ -0,0 +1,49 @@
/**
* 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 Glyph from './Glyph';
import Tooltip from './Tooltip';
import {colors} from './colors';
import styled from '@emotion/styled';
import React from 'react';
import {Tracked} from 'flipper-plugin';
type Props = React.ComponentProps<typeof ToolbarIconContainer> & {
active?: boolean;
icon: string;
title: string;
onClick: () => void;
};
const ToolbarIconContainer = styled.div({
marginRight: 9,
marginTop: -3,
marginLeft: 4,
position: 'relative', // for settings popover positioning
});
export default function ToolbarIcon({active, icon, title, ...props}: Props) {
return (
<Tooltip title={title}>
<Tracked action={title}>
<ToolbarIconContainer {...props}>
<Glyph
name={icon}
size={16}
color={
active
? colors.macOSTitleBarIconSelected
: colors.macOSTitleBarIconActive
}
/>
</ToolbarIconContainer>
</Tracked>
</Tooltip>
);
}

View File

@@ -0,0 +1,67 @@
/**
* 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 {TooltipOptions, TooltipContext} from './TooltipProvider';
import styled from '@emotion/styled';
import React, {useContext, useCallback, useRef, useEffect} from 'react';
const TooltipContainer = styled.div({
display: 'contents',
});
TooltipContainer.displayName = 'Tooltip:TooltipContainer';
type TooltipProps = {
/** Content shown in the tooltip */
title: React.ReactNode;
/** Component that will show the tooltip */
children: React.ReactNode;
options?: TooltipOptions;
};
/**
* @deprecated use Tooltip from 'tantd'
*/
export default function Tooltip(props: TooltipProps) {
const tooltipManager = useContext(TooltipContext);
const ref = useRef<HTMLDivElement | null>();
const isOpen = useRef<boolean>(false);
const {title, options} = props;
useEffect(
() => () => {
if (isOpen.current) {
tooltipManager.close();
}
},
[title, options],
);
const onMouseEnter = useCallback(() => {
if (ref.current && title) {
tooltipManager.open(ref.current, title, options || {});
isOpen.current = true;
}
}, [options, title, isOpen, ref]);
const onMouseLeave = useCallback(() => {
if (isOpen.current) {
tooltipManager.close();
isOpen.current = false;
}
}, [isOpen]);
return (
<TooltipContainer
ref={ref as any}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
{props.children}
</TooltipContainer>
);
}

View File

@@ -0,0 +1,281 @@
/**
* 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 {colors} from './colors';
import {memo, createContext, useMemo, useState, useRef} from 'react';
import {Property} from 'csstype';
import React from 'react';
const defaultOptions = {
backgroundColor: colors.blueGray,
position: 'below',
color: colors.white,
showTail: true,
maxWidth: '200px',
width: 'auto',
borderRadius: 4,
padding: '6px',
lineHeight: '20px',
delay: 0,
};
export type TooltipOptions = {
backgroundColor?: string;
position?: 'below' | 'above' | 'toRight' | 'toLeft';
color?: string;
showTail?: boolean;
maxWidth?: string;
width?: string;
borderRadius?: number;
padding?: string;
lineHeight?: string;
delay?: number; // in milliseconds
};
const TooltipBubble = styled.div<{
top: Property.Top<number>;
left: Property.Left<number>;
bottom: Property.Bottom<number>;
right: Property.Right<number>;
options: {
backgroundColor: Property.BackgroundColor;
lineHeight: Property.LineHeight<number>;
padding: Property.Padding<number>;
borderRadius: Property.BorderRadius<number>;
width: Property.Width<number>;
maxWidth: Property.MaxWidth<number>;
color: Property.Color;
};
}>((props) => ({
position: 'absolute',
zIndex: 99999999999,
backgroundColor: props.options.backgroundColor,
lineHeight: props.options.lineHeight,
padding: props.options.padding,
borderRadius: props.options.borderRadius,
width: props.options.width,
maxWidth: props.options.maxWidth,
top: props.top,
left: props.left,
bottom: props.bottom,
right: props.right,
color: props.options.color,
}));
TooltipBubble.displayName = 'TooltipProvider:TooltipBubble';
// vertical offset on bubble when position is 'below'
const BUBBLE_BELOW_POSITION_VERTICAL_OFFSET = -10;
// horizontal offset on bubble when position is 'toLeft' or 'toRight'
const BUBBLE_LR_POSITION_HORIZONTAL_OFFSET = 5;
// offset on bubble when tail is showing
const BUBBLE_SHOWTAIL_OFFSET = 5;
// horizontal offset on tail when position is 'above' or 'below'
const TAIL_AB_POSITION_HORIZONTAL_OFFSET = 4;
// horizontal offset on tail when position is 'toLeft' or 'toRight'
const TAIL_LR_POSITION_HORIZONTAL_OFFSET = 5;
// vertical offset on tail when position is 'toLeft' or 'toRight'
const TAIL_LR_POSITION_VERTICAL_OFFSET = 12;
const TooltipTail = styled.div<{
top: Property.Top<number>;
left: Property.Left<number>;
bottom: Property.Bottom<number>;
right: Property.Right<number>;
options: {
backgroundColor: Property.BackgroundColor;
};
}>((props) => ({
position: 'absolute',
display: 'block',
whiteSpace: 'pre',
height: '10px',
width: '10px',
lineHeight: '0',
zIndex: 99999999998,
transform: 'rotate(45deg)',
backgroundColor: props.options.backgroundColor,
top: props.top,
left: props.left,
bottom: props.bottom,
right: props.right,
}));
TooltipTail.displayName = 'TooltipProvider:TooltipTail';
type TooltipObject = {
rect: ClientRect;
title: React.ReactNode;
options: TooltipOptions;
};
interface TooltipManager {
open(
container: HTMLDivElement,
title: React.ReactNode,
options: TooltipOptions,
): void;
close(): void;
}
export const TooltipContext = createContext<TooltipManager>(undefined as any);
const TooltipProvider: React.FC<{}> = memo(function TooltipProvider({
children,
}) {
const timeoutID = useRef<NodeJS.Timeout>();
const [tooltip, setTooltip] = useState<TooltipObject | undefined>(undefined);
const tooltipManager = useMemo(
() => ({
open(
container: HTMLDivElement,
title: React.ReactNode,
options: TooltipOptions,
) {
if (timeoutID.current) {
clearTimeout(timeoutID.current);
}
const node = container.childNodes[0];
if (node == null || !(node instanceof HTMLElement)) {
return;
}
if (options.delay) {
timeoutID.current = setTimeout(() => {
setTooltip({
rect: node.getBoundingClientRect(),
title,
options: options,
});
}, options.delay);
return;
}
setTooltip({
rect: node.getBoundingClientRect(),
title,
options: options,
});
},
close() {
if (timeoutID.current) {
clearTimeout(timeoutID.current);
}
setTooltip(undefined);
},
}),
[],
);
return (
<>
{tooltip && tooltip.title ? <Tooltip tooltip={tooltip} /> : null}
<TooltipContext.Provider value={tooltipManager}>
{children}
</TooltipContext.Provider>
</>
);
});
function Tooltip({tooltip}: {tooltip: TooltipObject}) {
return (
<>
{getTooltipTail(tooltip)}
{getTooltipBubble(tooltip)}
</>
);
}
export default TooltipProvider;
function getTooltipTail(tooltip: TooltipObject) {
const opts = Object.assign(defaultOptions, tooltip.options);
if (!opts.showTail) {
return null;
}
let left: Property.Left<number> = 'auto';
let top: Property.Top<number> = 'auto';
let bottom: Property.Bottom<number> = 'auto';
let right: Property.Right<number> = 'auto';
if (opts.position === 'below') {
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.bottom;
} else if (opts.position === 'above') {
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
bottom = window.innerHeight - tooltip.rect.top;
} else if (opts.position === 'toRight') {
left = tooltip.rect.right + TAIL_LR_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.top + TAIL_LR_POSITION_VERTICAL_OFFSET;
} else if (opts.position === 'toLeft') {
right =
window.innerWidth -
tooltip.rect.left +
TAIL_LR_POSITION_HORIZONTAL_OFFSET;
top = tooltip.rect.top + TAIL_LR_POSITION_VERTICAL_OFFSET;
}
return (
<TooltipTail
key="tail"
top={top}
left={left}
bottom={bottom}
right={right}
options={opts}
/>
);
}
function getTooltipBubble(tooltip: TooltipObject) {
const opts = Object.assign(defaultOptions, tooltip.options);
let left: Property.Left<number> = 'auto';
let top: Property.Top<number> = 'auto';
let bottom: Property.Bottom<number> = 'auto';
let right: Property.Right<number> = 'auto';
if (opts.position === 'below') {
left = tooltip.rect.left + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
top = tooltip.rect.bottom;
if (opts.showTail) {
top += BUBBLE_SHOWTAIL_OFFSET;
}
} else if (opts.position === 'above') {
bottom = window.innerHeight - tooltip.rect.top;
if (opts.showTail) {
bottom += BUBBLE_SHOWTAIL_OFFSET;
}
left = tooltip.rect.left + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
} else if (opts.position === 'toRight') {
left = tooltip.rect.right + BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
if (opts.showTail) {
left += BUBBLE_SHOWTAIL_OFFSET;
}
top = tooltip.rect.top;
} else if (opts.position === 'toLeft') {
right =
window.innerWidth -
tooltip.rect.left +
BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
if (opts.showTail) {
right += BUBBLE_SHOWTAIL_OFFSET;
}
top = tooltip.rect.top;
}
return (
<TooltipBubble
key="bubble"
top={top}
left={left}
bottom={bottom}
right={right}
options={opts}>
{tooltip.title}
</TooltipBubble>
);
}

View File

@@ -0,0 +1,22 @@
/**
* 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 FlexColumn from './FlexColumn';
/**
* @deprecated use `Layout.Container` from flipper-plugin instead
* Container that applies a standardized bottom margin for vertical spacing
*/
const VBox = styled(FlexColumn)({
marginBottom: 10,
});
VBox.displayName = 'VBox';
export default VBox;

View File

@@ -0,0 +1,31 @@
/**
* 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';
type Props = {
grow?: boolean;
scrollable?: boolean;
maxHeight?: number;
};
/**
*
* @deprecated use `Layout.Container` from flipper-plugin instead
*/
const View = styled.div<Props>((props) => ({
height: props.grow ? '100%' : 'auto',
overflow: props.scrollable ? 'auto' : 'visible',
position: 'relative',
width: props.grow ? '100%' : 'auto',
maxHeight: props.maxHeight,
}));
View.displayName = 'View';
export default View;

View File

@@ -0,0 +1,74 @@
/**
* 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 from 'react';
import {render, fireEvent} from '@testing-library/react';
import ToolbarIcon from '../ToolbarIcon';
import TooltipProvider from '../TooltipProvider';
const TITLE_STRING = 'This is for testing';
test('rendering element icon without hovering', () => {
const res = render(
<ToolbarIcon title={TITLE_STRING} icon="target" onClick={() => {}} />,
);
expect(res.queryAllByText(TITLE_STRING).length).toBe(0);
});
test('trigger active for coverage(?)', () => {
const res = render(
<ToolbarIcon title={TITLE_STRING} icon="target" onClick={() => {}} />,
);
res.rerender(
<ToolbarIcon
active
title={TITLE_STRING}
icon="target"
onClick={() => {}}
/>,
);
res.rerender(
<ToolbarIcon title={TITLE_STRING} icon="target" onClick={() => {}} />,
);
});
test('test on hover and unhover', () => {
const res = render(
<TooltipProvider>
<ToolbarIcon title={TITLE_STRING} icon="target" onClick={() => {}} />
</TooltipProvider>,
);
expect(res.queryAllByText(TITLE_STRING).length).toBe(0);
const comp = res.container.firstChild?.childNodes[0];
expect(comp).not.toBeNull();
// hover
fireEvent.mouseEnter(comp!);
expect(res.queryAllByText(TITLE_STRING).length).toBe(1);
// unhover
fireEvent.mouseLeave(comp!);
expect(res.queryAllByText(TITLE_STRING).length).toBe(0);
});
test('test on click', () => {
const mockOnClick = jest.fn(() => {});
const res = render(
<TooltipProvider>
<ToolbarIcon title={TITLE_STRING} icon="target" onClick={mockOnClick} />
</TooltipProvider>,
);
const comp = res.container.firstChild?.childNodes[0];
expect(comp).not.toBeNull();
// click
fireEvent.click(comp!);
expect(mockOnClick.mock.calls.length).toBe(1);
});

View File

@@ -0,0 +1,303 @@
/**
* 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 {theme} from 'flipper-plugin';
// Last updated: Jan 30 2016
/**
* @deprecated use `theme` from 'flipper-plugin' instead, which exposes semantic colors that respect dark/light mode.
*/
export const colors = {
// FIG UI Core
blue: '#4267b2', // Blue - Active-state nav glyphs, nav bars, links, buttons
blueDark3: '#162643', // Blue - Dark 3 (illustrations only)
blueDark2: '#20375f', // Blue - Dark 2 (illustrations only)
blueDark1: '#29487d', // Blue - Dark 1 (illustrations only)
blueDark: '#365899', // Blue - Dark 0 (blue links, blue button hover states)
blueTint15: '#577fbc', // Blue - Tint 15 (illustrations only)
blueTint30: '#7596c8', // Blue - Tint 30 (illustrations only)
blueTint50: '#9cb4d8', // Blue - Tint 50 (illustrations only)
blueTint70: '#c4d2e7', // Blue - Tint 70 (illustrations only)
blueTint90: '#ecf0f7', // Blue - Tint 90 (illustrations only)
highlight: '#4080ff', // Highlight - Unread, badging notifications, NUX *Use sparingly.*
highlightDark3: '#1c4f8c', // Highlight - Dark 3 (illustrations only)
highlightDark2: '#1d5fbf', // Highlight - Dark 2 (illustrations only)
highlightDark1: '#3578e5', // Highlight - Dark 1 (illustrations only)
highlightTint15: '#5d93ff', // Highlight - Tint 15 (illustrations only)
highlightTint30: '#79a6ff', // Highlight - Tint 30 (illustrations only)
highlightTint50: '#9fbfff', // Highlight - Tint 50 (illustrations only)
highlightTint70: '#c6d9ff', // Highlight - Tint 70 (illustrations only)
highlightTint90: '#ecf2ff', // Highlight - Tint 90 (illustrations only)
highlightBackground: '#edf2fa', // Highlight Background - Background fill for unread or highlighted states. Not intended for hover / pressed states
highlighButtonPrimaryColor: '#237FF1', // Blue color which is used in the button when its type is primary
green: '#42b72a', // Green - Confirmation, success, commerce and status
red: '#FC3A4B', // Red - Badges, error states
redTint: '#FEF2F1',
white: '#ffffff', // White - Text and glyphs in Dark UI and media views
black: '#000000', // Black - Media backgrounds
yellow: '#D79651', // Yellow - Warnings
yellowTint: '#FEFBF2',
purple: '#8C73C8', // Purple - Verbose
purpleTint: '#E8E3F4',
purpleLight: '#ccc9d6', // purpleLight 90 - Highlighting row's background when it matches the query
gray: '#88A2AB', // Grey - Debug
grayTint: '#E7ECEE',
grayTint2: '#e5e5e5', // Grey - Can be used in demarcation with greyStackTraceTint
grayTint3: '#515151', // Grey - Can be used as the color for the title
grayStackTraceTint: '#f5f6f8', // Grey - It is used as the background for the stacktrace in crash reporter plugin
cyan: '#4FC9EA', // Cyan - Info
cyanTint: '#DCF4FB', // Cyan - Info
// FIG UI Light
light02: '#f6f7f9', // Light 02 Modal Headers & Nav - Modal headers and navigation elements that sit above primary UI
light05: '#e9ebee', // Light 05 Mobile & Desktop Wash - Background wash color for desktop and mobile
light10: '#dddfe2', // Light 10 Desktop Dividers, Strokes, Borders - Desktop dividers, strokes, borders
light15: '#ced0d4', // Light 15 Mobile Dividers, Strokes, Borders - Mobile dividers, strokes, borders
light20: '#bec2c9', // Light 20 Inactive Nav Glyphs - Inactive-state nav glyphs, tertiary glyphs
light30: '#90949c', // Light 30 Secondary Text & Glyphs - Secondary text and glyphs, meta text and glyphs
light50: '#4b4f56', // Light 50 Medium Text & Primary Glyphs - Medium text and primary glyphs
light80: '#1d2129', // Light 80 Primary Text - Primary text
// FIG UI Alpha
whiteAlpha10: 'rgba(255, 255, 255, 0.10)', // Alpha 10 - Inset strokes and borders on photos
whiteAlpha15: 'rgba(255, 255, 255, 0.15)', // Alpha 15 - Dividers, strokes, borders
whiteAlpha30: 'rgba(255, 255, 255, 0.3)', // Alpha 30 - Secondary text and glyphs, meta text and glyphs
whiteAlpha40: 'rgba(255, 255, 255, 0.4)', // Alpha 40 - Overlays
whiteAlpha50: 'rgba(255, 255, 255, 0.5)', // Alpha 50 - Medium text and primary glyphs
whiteAlpha80: 'rgba(255, 255, 255, 0.8)', // Alpha 80 - Primary Text
blackAlpha10: 'rgba(0, 0, 0, 0.1)', // Alpha 10 - Inset strokes and borders on photos
blackAlpha15: 'rgba(0, 0, 0, 0.15)', // Alpha 15 - Dividers, strokes, borders
blackAlpha30: 'rgba(0, 0, 0, 0.3)', // Alpha 30 - Secondary text and glyphs, meta text and glyphs
blackAlpha40: 'rgba(0, 0, 0, 0.4)', // Alpha 40 - Overlays
blackAlpha50: 'rgba(0, 0, 0, 0.5)', // Alpha 50 - Medium text and primary glyphs
blackAlpha80: 'rgba(0, 0, 0, 0.8)', // Alpha 80 - Primary Text
light80Alpha4: 'rgba(29, 33, 41, 0.04)', // Light 80 Alpha 4 - Hover state background fill for list views on WWW
light80Alpha8: 'rgba(29, 33, 41, 0.08)', // Light 80 Alpha 8 - Pressed state background fill for list views on WWW and Mobile
// FIG UI Dark
dark20: '#cccccc', // Dark 20 Primary Text - Primary text
dark50: '#7f7f7f', // Dark 50 Medium Text & Primary Glyphs - Medium text and primary glyphs
dark70: '#4c4c4c', // Dark 70 Secondary Text & Glyphs - Secondary text and glyphs, meta text and glyphs
dark80: '#333333', // Dark 80 Inactive Nav Glyphs - Inactive-state nav glyphs, tertiary glyphs
dark85: '#262626', // Dark 85 Dividers, Strokes, Borders - Dividers, strokes, borders
dark90: '#191919', // Dark 90 Nav Bar, Tab Bar, Cards - Nav bar, tab bar, cards
dark95: '#0d0d0d', // Dark 95 Background Wash - Background Wash
// FIG Spectrum
blueGray: '#5f6673', // Blue Grey
blueGrayDark3: '#23272f', // Blue Grey - Dark 3
blueGrayDark2: '#303846', // Blue Grey - Dark 2
blueGrayDark1: '#4f5766', // Blue Grey - Dark 1
blueGrayTint15: '#777d88', // Blue Grey - Tint 15
blueGrayTint30: '#8f949d', // Blue Grey - Tint 30
blueGrayTint50: '#afb3b9', // Blue Grey - Tint 50
blueGrayTint70: '#cfd1d5', // Blue Grey - Tint 70
blueGrayTint90: '#eff0f1', // Blue Grey - Tint 90
slate: '#b9cad2', // Slate
slateDark3: '#688694', // Slate - Dark 3
slateDark2: '#89a1ac', // Slate - Dark 2
slateDark1: '#a8bbc3', // Slate - Dark 1
slateTint15: '#c4d2d9', // Slate - Tint 15
slateTint30: '#cedae0', // Slate - Tint 30
slateTint50: '#dce5e9', // Slate - Tint 50
slateTint70: '#eaeff2', // Slate - Tint 70
slateTint90: '#f8fafb', // Slate - Tint 90
aluminum: '#a3cedf', // Aluminum
aluminumDark3: '#4b8096', // Aluminum - Dark 3
aluminumDark2: '#6ca0b6', // Aluminum - Dark 2
aluminumDark1: '#8ebfd4', // Aluminum - Dark 1
aluminumTint15: '#b0d5e5', // Aluminum - Tint 15
aluminumTint30: '#bfdde9', // Aluminum - Tint 30
aluminumTint50: '#d1e7f0', // Aluminum - Tint 50
aluminumTint70: '#e4f0f6', // Aluminum - Tint 70
aluminumTint90: '#f6fafc', // Aluminum - Tint 90
seaFoam: '#54c7ec', // Sea Foam
seaFoamDark3: '#186d90', // Sea Foam - Dark 3
seaFoamDark2: '#2088af', // Sea Foam - Dark 2
seaFoamDark1: '#39afd5', // Sea Foam - Dark 1
seaFoamTint15: '#6bcfef', // Sea Foam - Tint 15
seaFoamTint30: '#84d8f2', // Sea Foam - Tint 30
seaFoamTint50: '#a7e3f6', // Sea Foam - Tint 50
seaFoamTint70: '#caeef9', // Sea Foam - Tint 70
seaFoamTint90: '#eefafd', // Sea Foam - Tint 90
teal: '#6bcebb', // Teal
tealDark3: '#24917d', // Teal - Dark 3
tealDark2: '#31a38d', // Teal - Dark 2
tealDark1: '#4dbba6', // Teal - Dark 1
tealTint15: '#80d4c4', // Teal - Tint 15
tealTint30: '#97dccf', // Teal - Tint 30
tealTint50: '#b4e6dd', // Teal - Tint 50
tealTint70: '#d2f0ea', // Teal - Tint 70
tealTint90: '#f0faf8', // Teal - Tint 90
lime: '#a3ce71', // Lime
limeDark3: '#629824', // Lime - Dark 3
limeDark2: '#71a830', // Lime - Dark 2
limeDark1: '#89be4c', // Lime - Dark 1
limeTint15: '#b1d587', // Lime - Tint 15
limeTint30: '#bedd9c', // Lime - Tint 30
limeTint50: '#d1e6b9', // Lime - Tint 50
limeTint70: '#e4f0d5', // Lime - Tint 70
limeTint90: '#f6faf1', // Lime - Tint 90
lemon: '#fcd872', // Lemon
lemonDark3: '#d18f41', // Lemon - Dark 3
lemonDark2: '#e1a43b', // Lemon - Dark 2
lemonDark1: '#f5c33b', // Lemon - Dark 1
lemonTint15: '#ffe18f', // Lemon - Tint 15
lemonTint30: '#ffe8a8', // Lemon - Tint 30
lemonTint50: '#ffecb5', // Lemon - Tint 50
lemonTint70: '#fef2d1', // Lemon - Tint 70
lemonTint90: '#fffbf0', // Lemon - Tint 90
orange: '#f7923b', // Orange
orangeDark3: '#ac4615', // Orange - Dark 3
orangeDark2: '#cc5d22', // Orange - Dark 2
orangeDark1: '#e07a2e', // Orange - Dark 1
orangeTint15: '#f9a159', // Orange - Tint 15
orangeTint30: '#f9b278', // Orange - Tint 30
orangeTint50: '#fbc89f', // Orange - Tint 50
orangeTint70: '#fcdec5', // Orange - Tint 70
orangeTint90: '#fef4ec', // Orange - Tint 90
tomato: '#fb724b', // Tomato - Tometo? Tomato.
tomatoDark3: '#c32d0e', // Tomato - Dark 3
tomatoDark2: '#db4123', // Tomato - Dark 2
tomatoDark1: '#ef6632', // Tomato - Dark 1
tomatoTint15: '#f1765e', // Tomato - Tint 15
tomatoTint30: '#f38e7b', // Tomato - Tint 30
tomatoTint50: '#f7afa0', // Tomato - Tint 50
tomatoTint70: '#f9cfc7', // Tomato - Tint 70
tomatoTint90: '#fdefed', // Tomato - Tint 90
cherry: '#f35369', // Cherry
cherryDark3: '#9b2b3a', // Cherry - Dark 3
cherryDark2: '#b73749', // Cherry - Dark 2
cherryDark1: '#e04c60', // Cherry - Dark 1
cherryTint15: '#f36b7f', // Cherry - Tint 15
cherryTint30: '#f58796', // Cherry - Tint 30
cherryTint50: '#f8a9b4', // Cherry - Tint 50
cherryTint70: '#fbccd2', // Cherry - Tint 70
cherryTint90: '#feeef0', // Cherry - Tint 90
pink: '#ec7ebd', // Pink
pinkDark3: '#b0377b', // Pink - Dark 3
pinkDark2: '#d4539b', // Pink - Dark 2
pinkDark1: '#ec6fb5', // Pink - Dark 1
pinkTint15: '#ef92c7', // Pink - Tint 15
pinkTint30: '#f2a5d1', // Pink - Tint 30
pinkTint50: '#f6bfdf', // Pink - Tint 50
pinkTint70: '#f9d9eb', // Pink - Tint 70
pinkTint90: '#fdf3f8', // Pink - Tint 90
grape: '#8c72cb', // Grape
grapeDark3: '#58409b', // Grape - Dark 3
grapeDark2: '#6a51b2', // Grape - Dark 2
grapeDark1: '#7b64c0', // Grape - Dark 1
grapeTint15: '#9d87d2', // Grape - Tint 15
grapeTint30: '#af9cda', // Grape - Tint 30
grapeTint50: '#c6b8e5', // Grape - Tint 50
grapeTint70: '#ddd5f0', // Grape - Tint 70
grapeTint90: '#f4f1fa', // Grape - Tint 90
// FIG Spectrum (Skin)
skin1: '#f1d2b6', // Skin 1
skin1Dark3: '#d9a170', // Skin 1 - Dark 3
skin1Dark2: '#ddac82', // Skin 1 - Dark 2
skin1Dark1: '#e2ba96', // Skin 1 - Dark 1
skin1Tint15: '#f3d9c1', // Skin 1 - Tint 15
skin1Tint30: '#f6e0cc', // Skin 1 - Tint 30
skin1Tint50: '#f8e9db', // Skin 1 - Tint 50
skin1Tint70: '#faf2ea', // Skin 1 - Tint 70
skin1Tint90: '#fefbf8', // Skin 1 - Tint 90
skin2: '#d7b195', // Skin 2
skin2Dark3: '#af866a', // Skin 2 - Dark 3
skin2Dark2: '#c2977a', // Skin 2 - Dark 2
skin2Dark1: '#cfa588', // Skin 2 - Dark 1
skin2Tint15: '#debda5', // Skin 2 - Tint 15
skin2Tint30: '#e5c9b5', // Skin 2 - Tint 30
skin2Tint50: '#ecd8cb', // Skin 2 - Tint 50
skin2Tint70: '#f3e8e0', // Skin 2 - Tint 70
skin2Tint90: '#fbf7f5', // Skin 2 - Tint 90
skin3: '#d8a873', // Skin 3
skin3Dark3: '#a77a4e', // Skin 3 - Dark 3
skin3Dark2: '#ba8653', // Skin 3 - Dark 2
skin3Dark1: '#cd9862', // Skin 3 - Dark 1
skin3Tint15: '#e0b588', // Skin 3 - Tint 15
skin3Tint30: '#e5c29e', // Skin 3 - Tint 30
skin3Tint50: '#ecd4b9', // Skin 3 - Tint 50
skin3Tint70: '#f4e5d6', // Skin 3 - Tint 70
skin3Tint90: '#fcf6f1', // Skin 3 - Tint 90
skin4: '#a67b4f', // Skin 4
skin4Dark3: '#815830', // Skin 4 - Dark 3
skin4Dark2: '#94683d', // Skin 4 - Dark 2
skin4Dark1: '#a07243', // Skin 4 - Dark 1
skin4Tint15: '#ae8761', // Skin 4 - Tint 15
skin4Tint30: '#bc9d7d', // Skin 4 - Tint 30
skin4Tint50: '#d0b9a2', // Skin 4 - Tint 50
skin4Tint70: '#e2d5c8', // Skin 4 - Tint 70
skin4Tint90: '#f6f1ed', // Skin 4 - Tint 90
skin5: '#6a4f3b', // Skin 5
skin5Dark3: '#453223', // Skin 5 - Dark 3
skin5Dark2: '#503b2c', // Skin 5 - Dark 2
skin5Dark1: '#624733', // Skin 5 - Dark 1
skin5Tint15: '#8a715b', // Skin 5 - Tint 15
skin5Tint30: '#9f8a79', // Skin 5 - Tint 30
skin5Tint50: '#baaca0', // Skin 5 - Tint 50
skin5Tint70: '#d5cdc6', // Skin 5 - Tint 70
skin5Tint90: '#f2efec', // Skin 5 - Tint 90
// macOS system colors
macOSHighlight: '#dbe7fa', // used for text selection, tokens, etc.
macOSHighlightActive: '#85afee', // active tokens
macOSTitleBarBackgroundTop: '#eae9eb',
macOSTitleBarBackgroundBottom: '#dcdbdc',
macOSTitleBarBackgroundBlur: '#f6f6f6',
macOSTitleBarBorder: '#c1c0c2',
macOSTitleBarBorderBlur: '#cecece',
macOSTitleBarIcon: '#6f6f6f',
macOSTitleBarIconBlur: '#acacac',
macOSTitleBarIconSelected: '#4d84f5',
macOSTitleBarIconSelectedBlur: '#80a6f5',
macOSTitleBarIconActive: '#4c4c4c',
macOSTitleBarButtonBorder: '#d3d2d3',
macOSTitleBarButtonBorderBottom: '#b0afb0',
macOSTitleBarButtonBorderBlur: '#dbdbdb',
macOSTitleBarButtonBackground: 'rgba(0,0,0,0.05)',
macOSTitleBarButtonBackgroundBlur: '#f6f6f6',
macOSTitleBarButtonBackgroundActiveHighlight: '#ededed',
macOSTitleBarButtonBackgroundActive: '#e5e5e5',
macOSSidebarSectionTitle: '#777',
macOSSidebarSectionItem: '#434343',
macOSSidebarPanelSeperator: '#b3b3b3',
sectionHeaderBorder: '#DDDFE2',
placeholder: '#A7AAB1',
info: '#5ACFEC',
// Warning colors
warningTint: '#ecd9ad',
};
export const darkColors = {
activeBackground: colors.dark80,
backgroundWash: colors.dark95,
barBackground: colors.dark90,
barText: colors.dark20,
dividers: colors.whiteAlpha10,
};
export const brandColors = {
Facebook: '#0D7BED',
Lite: '#0D7BED',
Messenger: '#0088FA',
Instagram: '#E61E68',
WhatsApp: '#25D366',
Workplace: '#20262c',
'Work Chat': '#20262c',
Flipper: theme.primaryColor,
};
// https://www.internalfb.com/intern/assets/set/facebook_icons/
export const brandIcons = {
Facebook: 'app-facebook-f',
Lite: 'app-facebook-f',
Messenger: 'app-messenger',
Instagram: 'app-instagram',
WhatsApp: 'app-whatsapp',
Workplace: 'app-workplace',
'Work Chat': 'app-work-chat',
Flipper: 'list-gear', // used for the self inspection client
};

View File

@@ -0,0 +1,13 @@
/**
* 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 enum ElementFramework {
'LITHO',
'CK',
}

View File

@@ -0,0 +1,117 @@
/**
* 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 from 'react';
import ReactDOM from 'react-dom';
import {ElementsInspectorElement} from 'flipper-plugin';
import styled from '@emotion/styled';
export function VisualizerPortal(props: {
container: HTMLElement;
highlightedElement: string | null;
elements: {[key: string]: ElementsInspectorElement};
screenshotURL: string;
screenDimensions: {width: number; height: number};
}) {
props.container.style.margin = '0';
const element: ElementsInspectorElement | null | '' =
props.highlightedElement && props.elements[props.highlightedElement];
const position =
element &&
typeof element.data.View?.positionOnScreenX == 'number' &&
typeof element.data.View?.positionOnScreenY == 'number' &&
typeof element.data.View.width === 'object' &&
element.data.View.width.value != null &&
typeof element.data.View.height === 'object' &&
element.data.View.height.value != null
? {
x: element.data.View.positionOnScreenX,
y: element.data.View.positionOnScreenY,
width: element.data.View.width.value,
height: element.data.View.height.value,
}
: null;
return ReactDOM.createPortal(
<Visualizer
screenDimensions={props.screenDimensions}
element={position}
imageURL={props.screenshotURL}
/>,
props.container,
);
}
const VisualizerContainer = styled.div({
position: 'relative',
top: 0,
left: 0,
width: '100%',
height: '100%',
userSelect: 'none',
});
const DeviceImage = styled.img<{
width?: number | string;
height?: number | string;
}>(({width, height}) => ({
width,
height,
userSelect: 'none',
}));
/**
* Component that displays a static picture of a device
* and renders "highlighted" rectangles over arbitrary points on it.
* Used for emulating the layout plugin when a device isn't connected.
*/
function Visualizer(props: {
screenDimensions: {width: number; height: number};
element: {x: number; y: number; width: number; height: number} | null;
imageURL: string;
}) {
const containerRef: React.Ref<HTMLDivElement> = React.createRef();
const imageRef: React.Ref<HTMLImageElement> = React.createRef();
let w: number = 0;
let h: number = 0;
const [scale, updateScale] = React.useState(1);
React.useLayoutEffect(() => {
w = containerRef.current?.offsetWidth || 0;
h = containerRef.current?.offsetHeight || 0;
const xScale = props.screenDimensions.width / w;
const yScale = props.screenDimensions.height / h;
updateScale(Math.max(xScale, yScale));
imageRef.current?.setAttribute('draggable', 'false');
});
return (
<VisualizerContainer ref={containerRef}>
<DeviceImage
ref={imageRef}
src={props.imageURL}
width={props.screenDimensions.width / scale}
height={props.screenDimensions.height / scale}
/>
{props.element && (
<div
style={{
position: 'absolute',
left: props.element.x / scale,
top: props.element.y / scale,
width: props.element.width / scale,
height: props.element.height / scale,
backgroundColor: '#637dff',
opacity: 0.7,
userSelect: 'none',
}}></div>
)}
</VisualizerContainer>
);
}

View File

@@ -0,0 +1,162 @@
/**
* 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 {ElementsInspectorElement} from 'flipper-plugin';
import {PluginClient} from '../../../plugin';
import Client from '../../../Client';
import {Logger} from 'flipper-common';
import Panel from '../Panel';
import {DataInspector} from 'flipper-plugin';
import {Component} from 'react';
import React from 'react';
import deepEqual from 'deep-equal';
type OnValueChanged = (path: Array<string>, val: any) => void;
type InspectorSidebarSectionProps = {
data: any;
id: string;
onValueChanged: OnValueChanged | undefined | null;
tooltips?: Object;
};
class InspectorSidebarSection extends Component<InspectorSidebarSectionProps> {
setValue = (path: Array<string>, value: any) => {
if (this.props.onValueChanged) {
this.props.onValueChanged([this.props.id, ...path], value);
}
};
shouldComponentUpdate(nextProps: InspectorSidebarSectionProps) {
return (
!deepEqual(nextProps, this.props) ||
this.props.id !== nextProps.id ||
this.props.onValueChanged !== nextProps.onValueChanged
);
}
extractValue = (val: any) => {
if (val && val.__type__) {
return {
mutable: Boolean(val.__mutable__),
type: val.__type__ === 'auto' ? typeof val.value : val.__type__,
value: val.value,
};
} else {
return {
mutable: typeof val === 'object',
type: typeof val,
value: val,
};
}
};
render() {
const {id} = this.props;
return (
<Panel heading={id} floating={false} grow={false}>
<DataInspector
data={this.props.data}
setValue={this.props.onValueChanged ? this.setValue : undefined}
extractValue={this.extractValue}
expandRoot
collapsed
tooltips={this.props.tooltips}
/>
</Panel>
);
}
}
type Props = {
element: ElementsInspectorElement | undefined | null;
tooltips?: Object;
onValueChanged: OnValueChanged | undefined | null;
client: PluginClient;
realClient: Client;
logger: Logger;
extensions?: Array<Function>;
};
type State = {};
export class InspectorSidebar extends Component<Props, State> {
state = {};
constructor(props: Props) {
super(props);
}
render() {
const {element, extensions} = this.props;
if (!element || !element.data) {
return null;
}
const sections: Array<any> =
(extensions &&
extensions.map((ext) =>
ext(
this.props.client,
this.props.realClient,
element.id,
this.props.logger,
),
)) ||
[];
for (const key in element.data) {
if (key === 'Extra Sections') {
for (const extraSection in element.data[key]) {
let data:
| string
| number
| boolean
| {__type__: string; value: any}
| null = element.data[key][extraSection];
// data might be sent as stringified JSON, we want to parse it for a nicer persentation.
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) {
// data was not a valid JSON, type is required to be an object
console.error(
`ElementsInspector unable to parse extra section: ${extraSection}`,
);
data = null;
}
}
sections.push(
<InspectorSidebarSection
tooltips={this.props.tooltips}
key={extraSection}
id={extraSection}
data={data}
onValueChanged={this.props.onValueChanged}
/>,
);
}
} else {
sections.push(
<InspectorSidebarSection
tooltips={this.props.tooltips}
key={key}
id={key}
data={element.data[key]}
onValueChanged={this.props.onValueChanged}
/>,
);
}
}
return sections;
}
}

View File

@@ -0,0 +1,86 @@
/**
* 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 {Filter} from './types';
import React, {PureComponent} from 'react';
import ContextMenu from '../ContextMenu';
import {textContent} from 'flipper-plugin';
import styled from '@emotion/styled';
import {colors} from '../colors';
const FilterText = styled.div({
display: 'flex',
alignSelf: 'baseline',
cursor: 'pointer',
position: 'relative',
maxWidth: '100%',
'&:hover': {
color: colors.white,
zIndex: 2,
},
'&:hover::after': {
content: '""',
position: 'absolute',
top: 2,
bottom: 1,
left: -6,
right: -6,
borderRadius: '999em',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
zIndex: -1,
},
'&:hover *': {
color: `${colors.white} !important`,
},
});
FilterText.displayName = 'FilterRow:FilterText';
type Props = {
children: React.ReactNode;
addFilter: (filter: Filter) => void;
filterKey: string;
};
export default class FilterRow extends PureComponent<Props> {
onClick = (e: React.MouseEvent) => {
if (e.button === 0) {
this.props.addFilter({
type: e.metaKey || e.altKey ? 'exclude' : 'include',
key: this.props.filterKey,
value: textContent(this.props.children),
});
}
};
menuItems = [
{
label: 'Filter this value',
click: () =>
this.props.addFilter({
type: 'include',
key: this.props.filterKey,
value: textContent(this.props.children),
}),
},
];
render() {
const {children, ...props} = this.props;
return (
<ContextMenu
items={this.menuItems}
component={FilterText}
onMouseDown={this.onClick}
{...props}>
{children}
</ContextMenu>
);
}
}

View File

@@ -0,0 +1,26 @@
/**
* 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 Filter =
| {
key: string;
value: string;
type: 'include' | 'exclude';
}
| {
key: string;
value: Array<string>;
type: 'enum';
enum: Array<{
label: string;
color?: string;
value: string;
}>;
persistent?: boolean;
};

View File

@@ -0,0 +1,234 @@
/**
* 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 {Filter} from '../filter/types';
import {PureComponent} from 'react';
import Text from '../Text';
import styled from '@emotion/styled';
import React from 'react';
import {Property} from 'csstype';
import {theme} from 'flipper-plugin';
import {ContextMenuItem, createContextMenu} from '../ContextMenu';
import {Dropdown} from 'antd';
const Token = styled(Text)<{focused?: boolean; color?: Property.Color}>(
(props) => ({
display: 'inline-flex',
alignItems: 'center',
backgroundColor: props.color || theme.buttonDefaultBackground,
borderRadius: 4,
marginRight: 4,
padding: 4,
paddingLeft: 6,
height: 21,
'&:active': {
backgroundColor: theme.textColorActive,
color: theme.textColorPrimary,
},
'&:first-of-type': {
marginLeft: 3,
},
}),
);
Token.displayName = 'FilterToken:Token';
const Key = styled(Text)<{
type: 'exclude' | 'include' | 'enum';
focused?: boolean;
}>((props) => ({
position: 'relative',
fontWeight: 500,
paddingRight: 12,
lineHeight: '21px',
'&:after': {
content: props.type === 'exclude' ? '"≠"' : '"="',
paddingLeft: 5,
position: 'absolute',
top: -1,
right: 0,
fontSize: 14,
},
'&:active:after': {
backgroundColor: theme.textColorActive,
},
}));
Key.displayName = 'FilterToken:Key';
const Value = styled(Text)({
whiteSpace: 'nowrap',
maxWidth: 160,
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '21px',
paddingLeft: 3,
});
Value.displayName = 'FilterToken:Value';
const Chevron = styled.div<{focused?: boolean}>((props) => ({
border: 0,
paddingLeft: 3,
paddingRight: 1,
marginRight: 0,
fontSize: 16,
backgroundColor: 'transparent',
position: 'relative',
top: -2,
height: 'auto',
lineHeight: 'initial',
color: props.focused ? theme.textColorActive : 'inherit',
'&:hover, &:active, &:focus': {
color: 'inherit',
border: 0,
backgroundColor: 'transparent',
},
}));
Chevron.displayName = 'FilterToken:Chevron';
type Props = {
filter: Filter;
focused: boolean;
index: number;
onFocus: (focusedToken: number) => void;
onBlur: () => void;
onDelete: (deletedToken: number) => void;
onReplace: (index: number, filter: Filter) => void;
};
export default class FilterToken extends PureComponent<Props> {
onMouseDown = () => {
if (
this.props.filter.type !== 'enum' ||
this.props.filter.persistent == null ||
this.props.filter.persistent === false
) {
this.props.onFocus(this.props.index);
}
this.showDetails();
};
showDetails = () => {
const menuTemplate: Array<ContextMenuItem> = [];
if (this.props.filter.type === 'enum') {
menuTemplate.push(
...this.props.filter.enum.map(({value, label}) => ({
label,
click: () => this.changeEnum(value),
type: 'checkbox' as 'checkbox',
checked: this.props.filter.value.indexOf(value) > -1,
})),
);
} else {
if (this.props.filter.value.length > 23) {
menuTemplate.push(
{
label: this.props.filter.value,
enabled: false,
},
{
type: 'separator',
},
);
}
menuTemplate.push(
{
label:
this.props.filter.type === 'include'
? `Entries excluding "${this.props.filter.value}"`
: `Entries including "${this.props.filter.value}"`,
click: this.toggleFilter,
},
{
label: 'Remove this filter',
click: () => this.props.onDelete(this.props.index),
},
);
}
return createContextMenu(menuTemplate);
};
toggleFilter = () => {
const {filter, index} = this.props;
if (filter.type !== 'enum') {
const newFilter: Filter = {
...filter,
type: filter.type === 'include' ? 'exclude' : 'include',
};
this.props.onReplace(index, newFilter);
}
};
changeEnum = (newValue: string) => {
const {filter, index} = this.props;
if (filter.type === 'enum') {
let {value} = filter;
if (value.indexOf(newValue) > -1) {
value = value.filter((v) => v !== newValue);
} else {
value = value.concat([newValue]);
}
if (value.length === filter.enum.length) {
value = [];
}
const newFilter: Filter = {
...filter,
type: 'enum',
value,
};
this.props.onReplace(index, newFilter);
}
};
render() {
const {filter} = this.props;
let color;
let value = '';
if (filter.type === 'enum') {
const getEnum = (value: string) =>
filter.enum.find((e) => e.value === value);
const firstValue = getEnum(filter.value[0]);
const secondValue = getEnum(filter.value[1]);
if (filter.value.length === 0) {
value = 'All';
} else if (filter.value.length === 2 && firstValue && secondValue) {
value = `${firstValue.label} or ${secondValue.label}`;
} else if (filter.value.length === 1 && firstValue) {
value = firstValue.label;
color = firstValue.color;
} else if (firstValue) {
value = `${firstValue.label} or ${filter.value.length - 1} others`;
}
} else {
value = filter.value;
}
return (
<Dropdown trigger={dropdownTrigger} overlay={this.showDetails}>
<Token
key={`${filter.key}:${value}=${filter.type}`}
tabIndex={-1}
onMouseDown={this.onMouseDown}
focused={this.props.focused}
color={color}>
<Key type={this.props.filter.type} focused={this.props.focused}>
{filter.key}
</Key>
<Value>{value}</Value>
<Chevron tabIndex={-1} focused={this.props.focused}>
&#8964;
</Chevron>
</Token>
</Dropdown>
);
}
}
const dropdownTrigger = ['click' as const];

View File

@@ -0,0 +1,553 @@
/**
* 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 {Filter} from '../filter/types';
import {TableColumns} from '../table/types';
import {PureComponent} from 'react';
import Input from '../Input';
import Text from '../Text';
import FlexBox from '../FlexBox';
import Glyph from '../Glyph';
import FilterToken from './FilterToken';
import styled from '@emotion/styled';
import {debounce} from 'lodash';
import ToggleButton from '../ToggleSwitch';
import React from 'react';
import {Layout, theme, Toolbar} from 'flipper-plugin';
const SearchBar = styled(Toolbar)({
height: 42,
padding: 6,
});
SearchBar.displayName = 'Searchable:SearchBar';
export const SearchBox = styled(FlexBox)<{isInvalidInput?: boolean}>(
(props) => {
return {
flex: `1 0 auto`,
minWidth: 150,
height: 30,
backgroundColor: theme.backgroundDefault,
borderRadius: '999em',
border: `1px solid ${
!props.isInvalidInput ? theme.dividerColor : theme.errorColor
}`,
alignItems: 'center',
paddingLeft: 4,
};
},
);
SearchBox.displayName = 'Searchable:SearchBox';
export const SearchInput = styled(Input)<{
focus?: boolean;
regex?: boolean;
isValidInput?: boolean;
}>((props) => ({
border: props.focus ? '1px solid black' : 0,
...(props.regex ? {fontFamily: 'monospace'} : {}),
padding: 0,
fontSize: '1em',
flexGrow: 1,
height: 'auto',
lineHeight: '100%',
marginLeft: 2,
marginRight: 8,
width: '100%',
color:
props.regex && !props.isValidInput
? theme.errorColor
: theme.textColorPrimary,
'&::-webkit-input-placeholder': {
color: theme.textColorPlaceholder,
fontWeight: 300,
},
}));
SearchInput.displayName = 'Searchable:SearchInput';
const Clear = styled(Text)({
position: 'absolute',
right: 6,
top: '50%',
marginTop: -9,
fontSize: 16,
width: 17,
height: 17,
borderRadius: 999,
lineHeight: '15.5px',
textAlign: 'center',
backgroundColor: 'rgba(0,0,0,0.1)',
color: theme.textColorPrimary,
display: 'block',
'&:hover': {
backgroundColor: 'rgba(0,0,0,0.15)',
},
});
Clear.displayName = 'Searchable:Clear';
export const SearchIcon = styled(Glyph)({
marginRight: 3,
marginLeft: 3,
marginTop: -1,
minWidth: 16,
});
SearchIcon.displayName = 'Searchable:SearchIcon';
const Actions = styled(Layout.Horizontal)({
marginLeft: 8,
});
Actions.displayName = 'Searchable:Actions';
export type SearchableProps = {
addFilter: (filter: Filter) => void;
searchTerm: string;
filters: Array<Filter>;
allowRegexSearch?: boolean;
allowContentSearch?: boolean;
regexEnabled?: boolean;
contentSearchEnabled?: boolean;
};
type Props = {
placeholder?: string;
actions: React.ReactNode;
tableKey: string;
columns?: TableColumns;
onFilterChange: (filters: Array<Filter>) => void;
defaultFilters: Array<Filter>;
clearSearchTerm: boolean;
defaultSearchTerm: string;
allowRegexSearch: boolean;
allowContentSearch: boolean;
};
type State = {
filters: Array<Filter>;
focusedToken: number;
searchTerm: string;
hasFocus: boolean;
regexEnabled: boolean;
contentSearchEnabled: boolean;
compiledRegex: RegExp | null | undefined;
};
function compileRegex(s: string): RegExp | null {
try {
return new RegExp(s);
} catch (e) {
return null;
}
}
/**
* @deprecated use DataTabe / DataList instead
*
* Higher-order-component that allows adding a searchbar on top of the wrapped
* component. See SearchableManagedTable for usage with a table.
*/
export default function Searchable(
Component: React.ComponentType<any>,
): React.ComponentType<any> {
return class extends PureComponent<Props, State> {
static displayName = `Searchable(${Component.displayName})`;
static defaultProps = {
placeholder: 'Search...',
clearSearchTerm: false,
};
state: State = {
filters: this.props.defaultFilters || [],
focusedToken: -1,
searchTerm: this.props.defaultSearchTerm ?? '',
hasFocus: false,
regexEnabled: false,
contentSearchEnabled: false,
compiledRegex: null,
};
_inputRef: HTMLInputElement | undefined | null;
componentDidMount() {
window.document.addEventListener('keydown', this.onKeyDown);
const {defaultFilters} = this.props;
let savedState:
| {
filters: Array<Filter>;
regexEnabled?: boolean;
contentSearchEnabled?: boolean;
searchTerm?: string;
}
| undefined;
if (this.getTableKey()) {
try {
savedState = JSON.parse(
window.localStorage.getItem(this.getPersistKey()) || 'null',
);
} catch (e) {
window.localStorage.removeItem(this.getPersistKey());
}
}
if (savedState) {
if (defaultFilters != null) {
// merge default filter with persisted filters
const savedStateFilters = savedState.filters;
defaultFilters.forEach((defaultFilter) => {
const filterIndex = savedStateFilters.findIndex(
(f) => f.key === defaultFilter.key,
);
const savedDefaultFilter = savedStateFilters[filterIndex];
if (filterIndex > -1 && savedDefaultFilter.type === 'enum') {
if (defaultFilter.type === 'enum') {
savedDefaultFilter.enum = defaultFilter.enum;
}
const filters = new Set(
savedDefaultFilter.enum.map((filter) => filter.value),
);
savedStateFilters[filterIndex].value =
savedDefaultFilter.value.filter((value) => filters.has(value));
}
});
}
const searchTerm = this.props.clearSearchTerm
? this.props.defaultSearchTerm
: savedState.searchTerm || this.state.searchTerm;
this.setState({
searchTerm: searchTerm,
filters: savedState.filters || this.state.filters,
regexEnabled: savedState.regexEnabled || this.state.regexEnabled,
contentSearchEnabled:
savedState.contentSearchEnabled || this.state.contentSearchEnabled,
compiledRegex: compileRegex(searchTerm),
});
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (
this.getTableKey() &&
(prevState.searchTerm !== this.state.searchTerm ||
prevState.regexEnabled != this.state.regexEnabled ||
prevState.contentSearchEnabled != this.state.contentSearchEnabled ||
prevState.filters !== this.state.filters)
) {
window.localStorage.setItem(
this.getPersistKey(),
JSON.stringify({
searchTerm: this.state.searchTerm,
filters: this.state.filters,
regexEnabled: this.state.regexEnabled,
contentSearchEnabled: this.state.contentSearchEnabled,
}),
);
if (this.props.onFilterChange != null) {
this.props.onFilterChange(this.state.filters);
}
} else {
let mergedFilters = this.state.filters;
if (prevProps.defaultFilters !== this.props.defaultFilters) {
mergedFilters = [...this.state.filters];
this.props.defaultFilters.forEach((defaultFilter: Filter) => {
const filterIndex = mergedFilters.findIndex(
(f: Filter) => f.key === defaultFilter.key,
);
if (filterIndex > -1) {
mergedFilters[filterIndex] = defaultFilter;
} else {
mergedFilters.push(defaultFilter);
}
});
}
let newSearchTerm = this.state.searchTerm;
if (
prevProps.defaultSearchTerm !== this.props.defaultSearchTerm ||
prevProps.defaultFilters !== this.props.defaultFilters
) {
newSearchTerm = this.props.defaultSearchTerm ?? '';
}
this.setState({
filters: mergedFilters,
searchTerm: newSearchTerm,
});
}
}
componentWillUnmount() {
window.document.removeEventListener('keydown', this.onKeyDown);
}
getTableKey = (): string | null | undefined => {
if (this.props.tableKey) {
return this.props.tableKey;
} else if (this.props.columns) {
// if we have a table, we are using it's colums to uniquely identify
// the table (in case there is more than one table rendered at a time)
return (
'TABLE_COLUMNS_' +
Object.keys(this.props.columns).join('_').toUpperCase()
);
}
};
onKeyDown = (e: KeyboardEvent) => {
const ctrlOrCmd = (e: KeyboardEvent) =>
(e.metaKey && process.platform === 'darwin') ||
(e.ctrlKey && process.platform !== 'darwin');
if (e.key === 'f' && ctrlOrCmd(e) && this._inputRef) {
e.preventDefault();
if (this._inputRef) {
this._inputRef.focus();
}
} else if (e.key === 'Escape' && this._inputRef) {
this._inputRef.blur();
this.setState({searchTerm: ''});
} else if (e.key === 'Backspace' && this.hasFocus()) {
const lastFilter = this.state.filters[this.state.filters.length - 1];
if (
this.state.focusedToken === -1 &&
this.state.searchTerm === '' &&
this._inputRef &&
lastFilter &&
(lastFilter.type !== 'enum' || !lastFilter.persistent)
) {
this._inputRef.blur();
this.setState({focusedToken: this.state.filters.length - 1});
} else {
this.removeFilter(this.state.focusedToken);
}
} else if (
e.key === 'Delete' &&
this.hasFocus() &&
this.state.focusedToken > -1
) {
this.removeFilter(this.state.focusedToken);
} else if (e.key === 'Enter' && this.hasFocus() && this._inputRef) {
this.matchTags(this._inputRef.value, true);
this.setState({searchTerm: ''});
}
};
onChangeSearchTerm = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({
searchTerm: e.target.value,
compiledRegex: compileRegex(e.target.value),
});
this.matchTags(e.target.value, false);
};
matchTags = debounce((searchTerm: string, matchEnd: boolean) => {
const filterPattern = matchEnd
? /([a-z]\w*[!]?[:=]\S+)($|\s)/gi
: /([a-z]\w*[!]?[:=]\S+)\s/gi;
const match = searchTerm.match(filterPattern);
if (match && match.length > 0) {
match.forEach((filter: string) => {
const separator =
filter.indexOf(':') > filter.indexOf('=') ? ':' : '=';
let [key, ...values] = filter.split(separator);
let value = values.join(separator).trim();
let type: 'include' | 'exclude' | 'enum' = 'include';
// if value starts with !, it's an exclude filter
if (value.indexOf('!') === 0) {
type = 'exclude';
value = value.substring(1);
}
// if key ends with !, it's an exclude filter
if (key.indexOf('!') === key.length - 1) {
type = 'exclude';
key = key.slice(0, -1);
}
this.addFilter({
type,
key,
value,
});
});
searchTerm = searchTerm.replace(filterPattern, '');
}
}, 200);
setInputRef = (ref: HTMLInputElement | null) => {
this._inputRef = ref;
};
addFilter = (filter: Filter) => {
const filterIndex = this.state.filters.findIndex(
(f) => f.key === filter.key,
);
if (filterIndex > -1) {
const filters = [...this.state.filters];
const defaultFilter: Filter = this.props.defaultFilters?.[filterIndex];
const filter = filters[filterIndex];
if (
defaultFilter != null &&
defaultFilter.type === 'enum' &&
filter.type === 'enum'
) {
filter.enum = defaultFilter.enum;
}
this.setState({filters});
// filter for this key already exists
return;
}
// persistent filters are always at the front
const filters =
filter.type === 'enum' && filter.persistent === true
? [filter, ...this.state.filters]
: this.state.filters.concat(filter);
this.setState({
filters,
focusedToken: -1,
});
};
removeFilter = (index: number) => {
const filters = this.state.filters.filter((_, i) => i !== index);
const focusedToken = -1;
this.setState({filters, focusedToken}, () => {
if (this._inputRef) {
this._inputRef.focus();
}
});
};
replaceFilter = (index: number, filter: Filter) => {
const filters = [...this.state.filters];
filters.splice(index, 1, filter);
this.setState({filters});
};
onInputFocus = () =>
this.setState({
focusedToken: -1,
hasFocus: true,
});
onInputBlur = () =>
setTimeout(
() =>
this.setState({
hasFocus: false,
}),
100,
);
onTokenFocus = (focusedToken: number) => this.setState({focusedToken});
onTokenBlur = () => this.setState({focusedToken: -1});
onRegexToggled = () => {
this.setState({
regexEnabled: !this.state.regexEnabled,
compiledRegex: compileRegex(this.state.searchTerm),
});
};
onContentSearchToggled = () => {
this.setState({
contentSearchEnabled: !this.state.contentSearchEnabled,
});
};
hasFocus = (): boolean => {
return this.state.focusedToken !== -1 || this.state.hasFocus;
};
clear = () =>
this.setState({
filters: this.state.filters.filter(
(f) => f.type === 'enum' && f.persistent === true,
),
searchTerm: '',
});
getPersistKey = () => `SEARCHABLE_STORAGE_KEY_${this.getTableKey() || ''}`;
render() {
const {placeholder, actions, ...props} = this.props;
return (
<Layout.Top>
<SearchBar position="top" key="searchbar">
<SearchBox tabIndex={-1}>
<SearchIcon
name="magnifying-glass"
color={theme.textColorSecondary}
size={16}
/>
{this.state.filters.map((filter, i) => (
<FilterToken
key={`${filter.key}:${filter.type}`}
index={i}
filter={filter}
focused={i === this.state.focusedToken}
onFocus={this.onTokenFocus}
onDelete={this.removeFilter}
onReplace={this.replaceFilter}
onBlur={this.onTokenBlur}
/>
))}
<SearchInput
placeholder={placeholder}
onChange={this.onChangeSearchTerm}
value={this.state.searchTerm}
ref={this.setInputRef}
onFocus={this.onInputFocus}
onBlur={this.onInputBlur}
isValidInput={
this.state.regexEnabled
? this.state.compiledRegex !== null
: true
}
regex={Boolean(
this.state.regexEnabled && this.state.searchTerm,
)}
/>
{(this.state.searchTerm || this.state.filters.length > 0) && (
<Clear onClick={this.clear}>&times;</Clear>
)}
</SearchBox>
{this.props.allowRegexSearch ? (
<ToggleButton
toggled={this.state.regexEnabled}
onClick={this.onRegexToggled}
label={'Regex'}
/>
) : null}
{this.props.allowContentSearch ? (
<ToggleButton
toggled={this.state.contentSearchEnabled}
onClick={this.onContentSearchToggled}
label={'Contents'}
tooltip={
'Search the full item contents (warning: this can be quite slow)'
}
/>
) : null}
{actions != null && (
<Actions gap={theme.space.small}>{actions}</Actions>
)}
</SearchBar>
<Component
{...props}
key="table"
addFilter={this.addFilter}
searchTerm={this.state.searchTerm}
regexEnabled={this.state.regexEnabled}
contentSearchEnabled={this.state.contentSearchEnabled}
filters={this.state.filters}
/>
</Layout.Top>
);
}
};
}

View File

@@ -0,0 +1,167 @@
/**
* 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 {Filter} from '../filter/types';
import ManagedTable, {ManagedTableProps} from '../table/ManagedTable';
import {TableBodyRow} from '../table/types';
import Searchable, {SearchableProps} from './Searchable';
import React, {PureComponent} from 'react';
import {textContent} from 'flipper-plugin';
import deepEqual from 'deep-equal';
type Props = {
/** Reference to the table */
innerRef?: (ref: React.RefObject<any>) => void;
/** Filters that are added to the filterbar by default */
defaultFilters: Array<Filter>;
} & ManagedTableProps &
SearchableProps;
type State = {
filterRows: (row: TableBodyRow) => boolean;
};
export const rowMatchesFilters = (filters: Array<Filter>, row: TableBodyRow) =>
filters
.map((filter: Filter) => {
if (filter.type === 'enum' && row.type != null) {
return filter.value.length === 0 || filter.value.indexOf(row.type) > -1;
}
// Check if there is column name and value in case of mistyping.
if (
row.columns[filter.key] === undefined ||
row.columns[filter.key].value === undefined
) {
return false;
}
if (filter.type === 'include') {
return (
textContent(row.columns[filter.key].value).toLowerCase() ===
filter.value.toLowerCase()
);
} else if (filter.type === 'exclude') {
return (
textContent(row.columns[filter.key].value).toLowerCase() !==
filter.value.toLowerCase()
);
} else {
return true;
}
})
.every((x) => x === true);
export function rowMatchesRegex(values: Array<string>, regex: string): boolean {
try {
const re = new RegExp(regex);
return values.some((x) => re.test(x));
} catch (e) {
return false;
}
}
export function rowMatchesSearchTerm(
searchTerm: string,
isRegex: boolean,
isContentSearchEnabled: boolean,
row: TableBodyRow,
): boolean {
if (searchTerm == null || searchTerm.length === 0) {
return true;
}
const rowValues = Object.keys(row.columns).map((key) =>
textContent(row.columns[key].value),
);
if (isContentSearchEnabled && typeof row.getSearchContent === 'function') {
rowValues.push(row.getSearchContent());
}
if (row.filterValue != null) {
rowValues.push(row.filterValue);
}
if (isRegex) {
return rowMatchesRegex(rowValues, searchTerm);
}
return rowValues.some((x) =>
x.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
export const filterRowsFactory =
(
filters: Array<Filter>,
searchTerm: string,
regexSearch: boolean,
contentSearch: boolean,
) =>
(row: TableBodyRow): boolean =>
rowMatchesFilters(filters, row) &&
rowMatchesSearchTerm(searchTerm, regexSearch, contentSearch, row);
class SearchableManagedTable extends PureComponent<Props, State> {
static defaultProps = {
defaultFilters: [],
};
state = {
filterRows: filterRowsFactory(
this.props.filters,
this.props.searchTerm,
this.props.regexEnabled || false,
this.props.contentSearchEnabled || false,
),
};
componentDidMount() {
this.props.defaultFilters.map(this.props.addFilter);
}
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (
nextProps.searchTerm !== this.props.searchTerm ||
nextProps.regexEnabled != this.props.regexEnabled ||
nextProps.contentSearchEnabled != this.props.contentSearchEnabled ||
!deepEqual(this.props.filters, nextProps.filters)
) {
this.setState({
filterRows: filterRowsFactory(
nextProps.filters,
nextProps.searchTerm,
nextProps.regexEnabled || false,
nextProps.contentSearchEnabled || false,
),
});
}
}
render() {
const {
addFilter,
searchTerm: _searchTerm,
filters: _filters,
innerRef,
rows,
...props
} = this.props;
return (
<ManagedTable
{...props}
filter={this.state.filterRows}
rows={rows.filter(this.state.filterRows)}
onAddFilter={addFilter}
innerRef={innerRef}
/>
);
}
}
/**
* Table with filter and searchbar, supports all properties a ManagedTable
* and Searchable supports.
*/
export default Searchable(SearchableManagedTable);

View File

@@ -0,0 +1,753 @@
/**
* 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 {
TableColumnOrder,
TableColumnSizes,
TableColumns,
TableHighlightedRows,
TableRowSortOrder,
TableRows,
TableBodyRow,
TableOnAddFilter,
} from './types';
import {ContextMenuItem, MenuTemplate} from '../ContextMenu';
import React from 'react';
import styled from '@emotion/styled';
import AutoSizer from 'react-virtualized-auto-sizer';
import {VariableSizeList as List} from 'react-window';
import TableHead from './TableHead';
import TableRow from './TableRow';
import ContextMenu from '../ContextMenu';
import FlexColumn from '../FlexColumn';
import createPaste from '../../../fb-stubs/createPaste';
import debounceRender from 'react-debounce-render';
import {debounce} from 'lodash';
import {DEFAULT_ROW_HEIGHT} from './types';
import {notNull} from '../../../utils/typeUtils';
import {getFlipperLib, textContent} from 'flipper-plugin';
const EMPTY_OBJECT = {};
Object.freeze(EMPTY_OBJECT);
export type ManagedTableProps = {
/**
* Column definitions.
*/
columns: TableColumns;
/**
* Row definitions.
*/
rows: TableRows;
/*
* Globally unique key for persisting data between uses of a table such as column sizes.
*/
tableKey?: string;
/**
* Whether the table has a border.
*/
floating?: boolean;
/**
* Whether a row can span over multiple lines. Otherwise lines cannot wrap and
* are truncated.
*/
multiline?: boolean;
/**
* Whether the body is scrollable. When this is set to `true` then the table
* is not scrollable.
*/
autoHeight?: boolean;
/**
* Order of columns.
*/
columnOrder?: TableColumnOrder;
/**
* Initial size of the columns.
*/
columnSizes?: TableColumnSizes;
/**
* Value to filter rows on. Alternative to the `filter` prop.
*/
filterValue?: string;
/**
* Callback to filter rows.
*/
filter?: (row: TableBodyRow) => boolean;
/**
* Callback when the highlighted rows change.
*/
onRowHighlighted?: (keys: TableHighlightedRows) => void;
/**
* Whether rows can be highlighted or not.
*/
highlightableRows?: boolean;
/**
* Whether multiple rows can be highlighted or not.
*/
multiHighlight?: boolean;
/**
* Height of each row.
*/
rowLineHeight?: number;
/**
* This makes it so the scroll position sticks to the bottom of the window.
* Useful for streaming data like requests, logs etc.
*/
stickyBottom?: boolean;
/**
* Used by SearchableTable to add filters for rows.
*/
onAddFilter?: TableOnAddFilter;
/**
* Enable or disable zebra striping.
*/
zebra?: boolean;
/**
* Whether to hide the column names at the top of the table.
*/
hideHeader?: boolean;
/**
* Rows that are highlighted initially.
*/
highlightedRows?: Set<string>;
/**
* Allows to create context menu items for rows.
*/
buildContextMenuItems?: () => MenuTemplate;
/**
* Callback when sorting changes.
*/
onSort?: (order: TableRowSortOrder) => void;
/**
* Initial sort order of the table.
*/
initialSortOrder?: TableRowSortOrder;
/**
* Table scroll horizontally, if needed
*/
horizontallyScrollable?: boolean;
/**
* Whether to allow navigation via arrow keys. Default: true
*/
enableKeyboardNavigation?: boolean;
/**
* Reference to the managed table.
*/
innerRef?:
| React.MutableRefObject<ManagedTable | undefined>
| ((ref: ManagedTable | undefined) => void);
};
type ManagedTableState = {
highlightedRows: Set<string>;
sortOrder?: TableRowSortOrder;
columnOrder: TableColumnOrder;
columnKeys: string[];
columnSizes: TableColumnSizes;
shouldScrollToBottom: boolean;
};
const Container = styled(FlexColumn)<{canOverflow?: boolean}>((props) => ({
overflow: props.canOverflow ? 'scroll' : 'visible',
flexGrow: 1,
height: '100%',
}));
Container.displayName = 'ManagedTable:Container';
const globalTableState: {[key: string]: TableColumnSizes} = {};
export class ManagedTable extends React.Component<
ManagedTableProps,
ManagedTableState
> {
static defaultProps = {
highlightableRows: true,
multiHighlight: false,
autoHeight: false,
enableKeyboardNavigation: true,
};
getTableKey = (): string => {
return (
'TABLE_COLUMNS_' + Object.keys(this.props.columns).join('_').toUpperCase()
);
};
tableRef = React.createRef<List>();
scrollRef: {
current: null | HTMLDivElement;
} = React.createRef();
dragStartIndex: number | null = null;
// We want to call scrollToHighlightedRows on componentDidMount. However, at
// this time, tableRef is still null, because AutoSizer needs one render to
// measure the size of the table. This is why we are using this flag to
// trigger actions on the first update instead.
firstUpdate = true;
constructor(props: ManagedTableProps) {
super(props);
const columnOrder =
JSON.parse(window.localStorage.getItem(this.getTableKey()) || 'null') ||
this.props.columnOrder ||
Object.keys(this.props.columns).map((key) => ({key, visible: true}));
this.state = {
columnOrder,
columnKeys: this.computeColumnKeys(columnOrder),
columnSizes:
this.props.tableKey && globalTableState[this.props.tableKey]
? globalTableState[this.props.tableKey]
: this.props.columnSizes || {},
highlightedRows: this.props.highlightedRows || new Set(),
sortOrder: this.props.initialSortOrder || undefined,
shouldScrollToBottom: Boolean(this.props.stickyBottom),
};
}
componentDidMount() {
if (typeof this.props.innerRef === 'function') {
this.props.innerRef(this);
} else if (this.props.innerRef) {
this.props.innerRef.current = this;
}
}
componentWillUnmount() {
if (typeof this.props.innerRef === 'function') {
this.props.innerRef(undefined);
} else if (this.props.innerRef) {
this.props.innerRef.current = undefined;
}
}
UNSAFE_componentWillReceiveProps(nextProps: ManagedTableProps) {
// if columnSizes has changed
if (nextProps.columnSizes !== this.props.columnSizes) {
this.setState({
columnSizes: {
...(this.state.columnSizes || {}),
...nextProps.columnSizes,
},
});
}
if (this.props.highlightedRows !== nextProps.highlightedRows) {
this.setState({highlightedRows: nextProps.highlightedRows || new Set()});
}
// if columnOrder has changed
if (
nextProps.columnOrder !== this.props.columnOrder &&
nextProps.columnOrder
) {
if (this.tableRef && this.tableRef.current) {
this.tableRef.current.resetAfterIndex(0, true);
}
this.setState({
columnOrder: nextProps.columnOrder,
columnKeys: this.computeColumnKeys(nextProps.columnOrder),
});
}
if (
this.props.rows.length > nextProps.rows.length &&
this.tableRef &&
this.tableRef.current
) {
// rows were filtered, we need to recalculate heights
this.tableRef.current.resetAfterIndex(0, true);
}
}
componentDidUpdate(
prevProps: ManagedTableProps,
prevState: ManagedTableState,
) {
if (
this.props.stickyBottom !== false &&
this.props.rows.length !== prevProps.rows.length &&
this.state.shouldScrollToBottom &&
this.state.highlightedRows.size < 2
) {
this.scrollToBottom();
} else if (
prevState.highlightedRows !== this.state.highlightedRows ||
this.firstUpdate
) {
this.scrollToHighlightedRows();
}
if (
this.props.stickyBottom &&
!this.state.shouldScrollToBottom &&
this.scrollRef &&
this.scrollRef.current &&
this.scrollRef.current.parentElement &&
this.scrollRef.current.parentElement instanceof HTMLElement &&
this.scrollRef.current.offsetHeight <=
this.scrollRef.current.parentElement.offsetHeight
) {
this.setState({shouldScrollToBottom: true});
}
this.firstUpdate = false;
}
computeColumnKeys(columnOrder: TableColumnOrder) {
return columnOrder.map((k) => (k.visible ? k.key : null)).filter(notNull);
}
scrollToHighlightedRows = () => {
const {current} = this.tableRef;
const {highlightedRows} = this.state;
if (current && highlightedRows && highlightedRows.size > 0) {
const highlightedRow = Array.from(highlightedRows)[0];
const index = this.props.rows.findIndex(
({key}) => key === highlightedRow,
);
if (index >= 0) {
current.scrollToItem(index);
}
}
};
onCopy = (withHeader: boolean) => {
getFlipperLib().writeTextToClipboard(
[
...(withHeader ? [this.getHeaderText()] : []),
this.getSelectedText(),
].join('\n'),
);
};
onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const {highlightedRows} = this.state;
if (highlightedRows.size === 0) {
return;
}
if (
((e.metaKey && process.platform === 'darwin') ||
(e.ctrlKey && process.platform !== 'darwin')) &&
e.keyCode === 67
) {
e.stopPropagation();
this.onCopy(false);
} else if (
(e.keyCode === 38 || e.keyCode === 40) &&
this.props.highlightableRows &&
this.props.enableKeyboardNavigation
) {
e.stopPropagation();
// arrow navigation
const {rows} = this.props;
const {highlightedRows} = this.state;
const lastItemKey = Array.from(this.state.highlightedRows).pop();
const lastItemIndex = this.props.rows.findIndex(
(row) => row.key === lastItemKey,
);
const newIndex = Math.min(
rows.length - 1,
Math.max(0, e.keyCode === 38 ? lastItemIndex - 1 : lastItemIndex + 1),
);
if (!e.shiftKey) {
highlightedRows.clear();
}
highlightedRows.add(rows[newIndex].key);
this.onRowHighlighted(highlightedRows, () => {
const {current} = this.tableRef;
if (current) {
current.scrollToItem(newIndex);
}
});
}
};
onRowHighlighted = (highlightedRows: Set<string>, cb?: () => void) => {
if (!this.props.highlightableRows) {
return;
}
this.setState({highlightedRows}, cb);
const {onRowHighlighted} = this.props;
if (onRowHighlighted) {
onRowHighlighted(Array.from(highlightedRows));
}
};
onSort = (sortOrder: TableRowSortOrder) => {
this.setState({sortOrder});
this.props.onSort && this.props.onSort(sortOrder);
};
onColumnOrder = (columnOrder: TableColumnOrder) => {
this.setState({columnOrder});
// persist column order
window.localStorage.setItem(
this.getTableKey(),
JSON.stringify(columnOrder),
);
};
onColumnResize = (id: string, width: number | string) => {
this.setState(({columnSizes}) => ({
columnSizes: {
...columnSizes,
[id]: width,
},
}));
if (!this.props.tableKey) {
return;
}
if (!globalTableState[this.props.tableKey]) {
globalTableState[this.props.tableKey] = {};
}
globalTableState[this.props.tableKey][id] = width;
};
scrollToBottom() {
const {current: tableRef} = this.tableRef;
if (tableRef && this.props.rows.length > 1) {
tableRef.scrollToItem(this.props.rows.length - 1);
}
}
onHighlight = (e: React.MouseEvent, row: TableBodyRow, index: number) => {
if (!this.props.highlightableRows) {
return;
}
if (e.shiftKey) {
// prevents text selection
e.preventDefault();
}
let {highlightedRows} = this.state;
const contextClick =
e.button !== 0 ||
(process.platform === 'darwin' && e.button === 0 && e.ctrlKey);
if (contextClick) {
if (!highlightedRows.has(row.key)) {
highlightedRows.clear();
highlightedRows.add(row.key);
}
return;
}
this.dragStartIndex = index;
document.addEventListener('mouseup', this.onStopDragSelecting);
if (
((process.platform === 'darwin' && e.metaKey) ||
(process.platform !== 'darwin' && e.ctrlKey)) &&
this.props.multiHighlight
) {
highlightedRows.add(row.key);
} else if (e.shiftKey && this.props.multiHighlight) {
// range select
const lastItemKey = Array.from(highlightedRows).pop()!;
highlightedRows = new Set([
...highlightedRows,
...this.selectInRange(lastItemKey, row.key),
]);
} else {
// single select
highlightedRows.clear();
highlightedRows.add(row.key);
}
this.onRowHighlighted(highlightedRows);
};
onStopDragSelecting = () => {
this.dragStartIndex = null;
document.removeEventListener('mouseup', this.onStopDragSelecting);
};
selectInRange = (fromKey: string, toKey: string): Array<string> => {
const selected = [];
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < this.props.rows.length; i++) {
if (this.props.rows[i].key === fromKey) {
startIndex = i;
}
if (this.props.rows[i].key === toKey) {
endIndex = i;
}
if (endIndex > -1 && startIndex > -1) {
break;
}
}
for (
let i = Math.min(startIndex, endIndex);
i <= Math.max(startIndex, endIndex);
i++
) {
try {
selected.push(this.props.rows[i].key);
} catch (e) {}
}
return selected;
};
onMouseEnterRow = (e: React.MouseEvent, row: TableBodyRow, index: number) => {
const {dragStartIndex} = this;
const {current} = this.tableRef;
if (
dragStartIndex &&
current &&
this.props.multiHighlight &&
this.props.highlightableRows &&
!e.shiftKey // When shift key is pressed, it's a range select not a drag select
) {
current.scrollToItem(index + 1);
const startKey = this.props.rows[dragStartIndex].key;
const highlightedRows = new Set(this.selectInRange(startKey, row.key));
this.onRowHighlighted(highlightedRows);
}
};
onCopyCell = (rowId: string, index: number) => {
const cellText = this.getTextContentOfRow(rowId)[index];
getFlipperLib().writeTextToClipboard(cellText);
};
buildContextMenuItems: () => Array<ContextMenuItem> = () => {
const {highlightedRows} = this.state;
if (highlightedRows.size === 0) {
return [];
}
const copyCellSubMenu =
highlightedRows.size === 1
? [
{
label: 'Copy cell',
submenu: this.state.columnOrder
.filter((c) => c.visible)
.map((c) => c.key)
.map((column, index) => ({
label: this.props.columns[column].value,
click: () => {
const rowId = this.state.highlightedRows
.values()
.next().value;
rowId && this.onCopyCell(rowId, index);
},
})),
},
]
: [];
return [
...copyCellSubMenu,
{
label:
highlightedRows.size > 1
? `Copy ${highlightedRows.size} rows`
: 'Copy row',
submenu: [
{label: 'With columns header', click: () => this.onCopy(true)},
{
label: 'Without columns header',
click: () => {
this.onCopy(false);
},
},
],
},
{
label: 'Create Paste',
click: () =>
createPaste(
[this.getHeaderText(), this.getSelectedText()].join('\n'),
),
},
];
};
getHeaderText = (): string => {
return this.state.columnOrder
.filter((c) => c.visible)
.map((c) => c.key)
.map((key) => this.props.columns[key].value)
.join('\t');
};
getSelectedText = (): string => {
const {highlightedRows} = this.state;
if (highlightedRows.size === 0) {
return '';
}
return this.props.rows
.filter((row) => highlightedRows.has(row.key))
.map((row: TableBodyRow) =>
typeof row.copyText === 'function'
? row.copyText()
: row.copyText || this.getTextContentOfRow(row.key).join('\t'),
)
.join('\n');
};
getTextContentOfRow = (key: string): Array<string> => {
const row = this.props.rows.find((row) => row.key === key);
if (!row) {
return [];
}
return this.state.columnOrder
.filter(({visible}) => visible)
.map(({key}) => textContent(row.columns[key].value));
};
onScroll = debounce(
({
scrollDirection,
scrollOffset,
}: {
scrollDirection: 'forward' | 'backward';
scrollOffset: number;
scrollUpdateWasRequested: boolean;
}) => {
const {current} = this.scrollRef;
const parent = current ? current.parentElement : null;
if (
this.props.stickyBottom &&
current &&
parent instanceof HTMLElement &&
scrollDirection === 'forward' &&
!this.state.shouldScrollToBottom &&
current.offsetHeight - parent.offsetHeight === scrollOffset
) {
this.setState({shouldScrollToBottom: true});
} else if (
this.props.stickyBottom &&
scrollDirection === 'backward' &&
this.state.shouldScrollToBottom
) {
this.setState({shouldScrollToBottom: false});
}
},
100,
);
getRow = ({index, style}: {index: number; style: React.CSSProperties}) => {
const {onAddFilter, multiline, zebra, rows} = this.props;
const {columnKeys, columnSizes, highlightedRows} = this.state;
return (
<TableRow
key={rows[index].key}
columnSizes={columnSizes}
columnKeys={columnKeys}
onMouseDown={this.onHighlight}
onMouseEnter={this.onMouseEnterRow}
multiline={multiline}
rowLineHeight={24}
highlighted={highlightedRows.has(rows[index].key)}
row={rows[index]}
index={index}
style={style}
onAddFilter={onAddFilter}
zebra={zebra}
/>
);
};
render() {
const {columns, rows, rowLineHeight, hideHeader, horizontallyScrollable} =
this.props;
const {columnOrder, columnSizes} = this.state;
let computedWidth = 0;
if (horizontallyScrollable) {
for (let index = 0; index < columnOrder.length; index++) {
const col = columnOrder[index];
if (!col.visible) {
continue;
}
const width = columnSizes[col.key];
if (typeof width === 'number' && isNaN(width)) {
// non-numeric columns with, can't caluclate
computedWidth = 0;
break;
} else {
computedWidth += parseInt(String(width), 10);
}
}
}
return (
<Container
canOverflow={horizontallyScrollable}
onKeyDown={this.onKeyDown}
tabIndex={0}>
{hideHeader !== true && (
<TableHead
columnOrder={columnOrder}
onColumnOrder={this.onColumnOrder}
columns={columns}
onColumnResize={this.onColumnResize}
sortOrder={this.state.sortOrder}
columnSizes={columnSizes}
onSort={this.onSort}
horizontallyScrollable={horizontallyScrollable}
/>
)}
<Container>
{this.props.autoHeight ? (
<ContextMenu
buildItems={
this.props.buildContextMenuItems || this.buildContextMenuItems
}>
{this.props.rows.map((_, index) =>
this.getRow({index, style: EMPTY_OBJECT}),
)}
</ContextMenu>
) : (
<AutoSizer>
{({width, height}) => (
<ContextMenu
buildItems={
this.props.buildContextMenuItems ||
this.buildContextMenuItems
}>
<List
itemCount={rows.length}
itemSize={(index) =>
(rows[index] && rows[index].height) ||
rowLineHeight ||
DEFAULT_ROW_HEIGHT
}
ref={this.tableRef}
width={Math.max(width, computedWidth)}
estimatedItemSize={rowLineHeight || DEFAULT_ROW_HEIGHT}
overscanCount={5}
innerRef={this.scrollRef}
onScroll={this.onScroll}
height={height}>
{this.getRow}
</List>
</ContextMenu>
)}
</AutoSizer>
)}
</Container>
</Container>
);
}
}
export default debounceRender(ManagedTable, 150, {maxWait: 250});

View File

@@ -0,0 +1,326 @@
/**
* 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 {
TableColumnOrder,
TableColumnSizes,
TableColumns,
TableOnColumnResize,
TableOnSort,
TableRowSortOrder,
} from './types';
import {normalizeColumnWidth, isPercentage} from './utils';
import {PureComponent} from 'react';
import ContextMenu, {ContextMenuItem} from '../ContextMenu';
import {theme, _Interactive, _InteractiveProps} from 'flipper-plugin';
import styled from '@emotion/styled';
import {colors} from '../colors';
import FlexRow from '../FlexRow';
import invariant from 'invariant';
import React from 'react';
const TableHeaderArrow = styled.span({
float: 'right',
});
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
const TableHeaderColumnInteractive = styled(_Interactive)<_InteractiveProps>({
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
});
TableHeaderColumnInteractive.displayName =
'TableHead:TableHeaderColumnInteractive';
const TableHeaderColumnContainer = styled.div({
padding: '0 8px',
});
TableHeaderColumnContainer.displayName = 'TableHead:TableHeaderColumnContainer';
const TableHeadContainer = styled(FlexRow)<{horizontallyScrollable?: boolean}>(
(props) => ({
borderBottom: `1px solid ${theme.dividerColor}`,
flexShrink: 0,
left: 0,
overflow: 'hidden',
right: 0,
textAlign: 'left',
top: 0,
zIndex: 2,
minWidth: props.horizontallyScrollable ? 'min-content' : 0,
}),
);
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
const TableHeadColumnContainer = styled.div<{width: string | number}>(
(props) => ({
position: 'relative',
backgroundColor: theme.backgroundWash,
flexShrink: props.width === 'flex' ? 1 : 0,
height: 23,
lineHeight: '23px',
fontSize: '0.85em',
fontWeight: 500,
width: props.width === 'flex' ? '100%' : props.width,
'&::after': {
position: 'absolute',
content: '""',
right: 0,
top: 5,
height: 13,
width: 1,
background: colors.light15,
},
'&:last-child::after': {
display: 'none',
},
}),
);
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
const RIGHT_RESIZABLE = {right: true};
function calculatePercentage(parentWidth: number, selfWidth: number): string {
return `${(100 / parentWidth) * selfWidth}%`;
}
class TableHeadColumn extends PureComponent<{
id: string;
width: string | number;
sortable?: boolean;
isResizable: boolean;
leftHasResizer: boolean;
hasFlex: boolean;
sortOrder?: TableRowSortOrder;
onSort?: TableOnSort;
columnSizes: TableColumnSizes;
onColumnResize?: TableOnColumnResize;
children?: React.ReactNode;
title?: string;
horizontallyScrollable?: boolean;
}> {
ref: HTMLElement | undefined | null;
componentDidMount() {
if (this.props.horizontallyScrollable && this.ref) {
// measure initial width
this.onResize(this.ref.offsetWidth);
}
}
onClick = () => {
const {id, onSort, sortOrder} = this.props;
const direction =
sortOrder && sortOrder.key === id && sortOrder.direction === 'down'
? 'up'
: 'down';
if (onSort) {
onSort({
direction,
key: id,
});
}
};
onResize = (newWidth: number) => {
const {id, onColumnResize, width} = this.props;
if (!onColumnResize) {
return;
}
let normalizedWidth: number | string = newWidth;
// normalise number to a percentage if we were originally passed a percentage
if (isPercentage(width) && this.ref) {
const {parentElement} = this.ref;
invariant(parentElement, 'expected there to be parentElement');
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);
};
setRef = (ref: HTMLElement | null) => {
this.ref = ref;
};
render() {
const {isResizable, sortable, width, title} = this.props;
let {children} = this.props;
children = (
<TableHeaderColumnContainer>{children}</TableHeaderColumnContainer>
);
if (isResizable) {
children = (
<TableHeaderColumnInteractive
grow
resizable={RIGHT_RESIZABLE}
onResize={this.onResize}
minWidth={20}>
{children}
</TableHeaderColumnInteractive>
);
}
return (
<TableHeadColumnContainer
width={width}
title={title}
onClick={sortable === true ? this.onClick : undefined}
ref={this.setRef}>
{children}
</TableHeadColumnContainer>
);
}
}
export default class TableHead extends PureComponent<{
columnOrder: TableColumnOrder;
onColumnOrder?: (order: TableColumnOrder) => void;
columns: TableColumns;
sortOrder?: TableRowSortOrder;
onSort?: TableOnSort;
columnSizes: TableColumnSizes;
onColumnResize?: TableOnColumnResize;
horizontallyScrollable?: boolean;
}> {
buildContextMenu = (): ContextMenuItem[] => {
const visibles = this.props.columnOrder
.map((c) => (c.visible ? c.key : null))
.filter(Boolean)
.reduce((acc, cv) => {
acc.add(cv);
return acc;
}, new Set());
return Object.keys(this.props.columns).map((key) => {
const visible = visibles.has(key);
return {
label: this.props.columns[key].value,
click: () => {
const {onColumnOrder, columnOrder} = this.props;
if (onColumnOrder) {
const newOrder = columnOrder.slice();
let hasVisibleItem = false;
for (let i = 0; i < newOrder.length; i++) {
const info = newOrder[i];
if (info.key === key) {
newOrder[i] = {key, visible: !visible};
}
hasVisibleItem = hasVisibleItem || newOrder[i].visible;
}
// Dont allow hiding all columns
if (hasVisibleItem) {
onColumnOrder(newOrder);
}
}
},
type: 'checkbox' as 'checkbox',
checked: visible,
};
});
};
render() {
const {
columnOrder,
columns,
columnSizes,
onColumnResize,
onSort,
sortOrder,
horizontallyScrollable,
} = this.props;
const elems = [];
let hasFlex = false;
for (const column of columnOrder) {
if (column.visible && columnSizes[column.key] === 'flex') {
hasFlex = true;
break;
}
}
let lastResizable = true;
const colElems: {
[key: string]: JSX.Element;
} = {};
for (const column of columnOrder) {
if (!column.visible) {
continue;
}
const key = column.key;
const col = columns[key];
let arrow;
if (col.sortable === true && sortOrder && sortOrder.key === key) {
arrow = (
<TableHeaderArrow>
{sortOrder.direction === 'up' ? '▲' : '▼'}
</TableHeaderArrow>
);
}
const width = normalizeColumnWidth(columnSizes[key]);
const isResizable = col.resizable !== false;
const elem = (
<TableHeadColumn
key={key}
id={key}
hasFlex={hasFlex}
isResizable={isResizable}
leftHasResizer={lastResizable}
width={width}
sortable={col.sortable}
sortOrder={sortOrder}
onSort={onSort}
columnSizes={columnSizes}
onColumnResize={onColumnResize}
title={key}
horizontallyScrollable={horizontallyScrollable}>
{col.value}
{arrow}
</TableHeadColumn>
);
elems.push(elem);
colElems[key] = elem;
lastResizable = isResizable;
}
return (
<ContextMenu buildItems={this.buildContextMenu}>
<TableHeadContainer horizontallyScrollable={horizontallyScrollable}>
{elems}
</TableHeadContainer>
</ContextMenu>
);
}
}

View File

@@ -0,0 +1,180 @@
/**
* 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 {
TableColumnKeys,
TableColumnSizes,
TableOnAddFilter,
TableBodyRow,
} from './types';
import React from 'react';
import FilterRow from '../filter/FilterRow';
import styled from '@emotion/styled';
import FlexRow from '../FlexRow';
import {normalizeColumnWidth} from './utils';
import {DEFAULT_ROW_HEIGHT} from './types';
import {Property} from 'csstype';
import {theme} from 'flipper-plugin';
type TableBodyRowContainerProps = {
even?: boolean;
zebra?: boolean;
highlighted?: boolean;
rowLineHeight?: number;
multiline?: boolean;
fontWeight?: Property.FontWeight;
color?: Property.Color;
highlightOnHover?: boolean;
backgroundColor?: Property.BackgroundColor;
highlightedBackgroundColor?: Property.BackgroundColor;
zebraBackgroundColor?: Property.BackgroundColor;
};
const backgroundColor = (props: TableBodyRowContainerProps) => {
if (props.highlighted) {
if (props.highlightedBackgroundColor) {
return props.highlightedBackgroundColor;
} else {
return theme.backgroundWash;
}
} else {
if (props.zebra && props.zebraBackgroundColor && props.backgroundColor) {
return props.even ? props.zebraBackgroundColor : props.backgroundColor;
} else if (props.backgroundColor) {
return props.backgroundColor;
} else {
return 'transparent';
}
}
};
const TableBodyRowContainer = styled(FlexRow)<TableBodyRowContainerProps>(
(props) => ({
backgroundColor: backgroundColor(props),
boxShadow: props.zebra ? 'none' : `inset 0 -1px ${theme.dividerColor}`,
color: theme.textColorPrimary,
height: props.multiline ? 'auto' : props.rowLineHeight,
lineHeight: `${String(props.rowLineHeight || DEFAULT_ROW_HEIGHT)}px`,
fontWeight: props.fontWeight,
overflow: 'hidden',
width: '100%',
flexShrink: 0,
}),
);
TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
const TableBodyColumnContainer = styled.div<{
width?: any;
multiline?: boolean;
justifyContent: Property.JustifyContent;
}>((props) => ({
display: 'flex',
flexShrink: props.width === 'flex' ? 1 : 0,
overflow: 'hidden',
padding: '0 8px',
textOverflow: 'ellipsis',
verticalAlign: 'top',
whiteSpace: props.multiline ? 'normal' : 'nowrap',
wordWrap: props.multiline ? 'break-word' : 'normal',
width: props.width === 'flex' ? '100%' : props.width,
maxWidth: '100%',
justifyContent: props.justifyContent,
}));
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
type Props = {
columnSizes: TableColumnSizes;
columnKeys: TableColumnKeys;
onMouseDown: (e: React.MouseEvent, row: TableBodyRow, index: number) => void;
onMouseEnter?: (
e: React.MouseEvent,
row: TableBodyRow,
index: number,
) => void;
multiline?: boolean;
rowLineHeight: number;
highlighted: boolean;
row: TableBodyRow;
index: number;
style?: React.CSSProperties | undefined;
onAddFilter?: TableOnAddFilter;
zebra?: boolean;
};
export default class TableRow extends React.PureComponent<Props> {
static defaultProps = {
zebra: true,
};
handleMouseDown = (e: React.MouseEvent) => {
this.props.onMouseDown(e, this.props.row, this.props.index);
};
handleMouseEnter = (e: React.MouseEvent) => {
this.props.onMouseEnter?.(e, this.props.row, this.props.index);
};
render() {
const {
index,
highlighted,
rowLineHeight,
row,
style,
multiline,
columnKeys,
columnSizes,
zebra,
onAddFilter,
} = this.props;
return (
<TableBodyRowContainer
rowLineHeight={rowLineHeight}
highlightedBackgroundColor={row.highlightedBackgroundColor}
backgroundColor={row.backgroundColor}
zebraBackgroundColor={row.zebraBackgroundColor}
highlighted={highlighted}
multiline={multiline}
even={index % 2 === 0}
zebra={zebra}
onMouseDown={this.handleMouseDown}
onMouseEnter={this.handleMouseEnter}
style={style}
highlightOnHover={row.highlightOnHover}
data-key={row.key}
{...row.style}>
{columnKeys.map((key) => {
const col = row.columns[key];
const isFilterable = Boolean(col && col.isFilterable);
const value = col && col.value ? col.value : null;
const title = col && col.title ? col.title : '';
return (
<TableBodyColumnContainer
key={key}
title={title}
multiline={multiline}
justifyContent={col && col.align ? col.align : 'flex-start'}
width={normalizeColumnWidth(columnSizes[key])}>
{isFilterable && onAddFilter != null ? (
<FilterRow addFilter={onAddFilter} filterKey={key}>
{value}
</FilterRow>
) : (
value
)}
</TableBodyColumnContainer>
);
})}
</TableBodyRowContainer>
);
}
}

View File

@@ -0,0 +1,90 @@
/**
* 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 {Filter} from '../filter/types';
import {Property} from 'csstype';
export const MINIMUM_COLUMN_WIDTH = 100;
export const DEFAULT_COLUMN_WIDTH = 200;
export const DEFAULT_ROW_HEIGHT = 23;
export type TableColumnOrderVal = {
key: string;
visible: boolean;
};
export type TableColumnOrder = Array<TableColumnOrderVal>;
export type TableColumnSizes = {
[key: string]: string | number;
};
export type TableHighlightedRows = Array<string>;
export type TableColumnKeys = Array<string>;
export type TableOnColumnResize = (id: string, size: number | string) => void;
export type TableOnColumnOrder = (order: TableColumnOrder) => void;
export type TableOnSort = (order: TableRowSortOrder) => void;
export type TableOnHighlight = (
highlightedRows: TableHighlightedRows,
e: React.UIEvent,
) => void;
export type TableHeaderColumn = {
value: string;
sortable?: boolean;
resizable?: boolean;
};
export type TableBodyRow = {
key: string;
height?: number | undefined;
filterValue?: string | undefined;
backgroundColor?: string | undefined;
sortKey?: string | number;
style?: Object;
type?: string | undefined;
highlightedBackgroundColor?: Property.BackgroundColor | undefined;
zebraBackgroundColor?: Property.BackgroundColor | undefined;
onDoubleClick?: (e: React.MouseEvent) => void;
copyText?: string | (() => string);
getSearchContent?: () => string;
highlightOnHover?: boolean;
columns: {
[key: string]: TableBodyColumn;
};
};
export type TableBodyColumn = {
sortValue?: string | number;
isFilterable?: boolean;
value: any;
align?: 'left' | 'center' | 'right' | 'flex-start' | 'flex-end';
title?: string;
};
export type TableColumns = {
[key: string]: TableHeaderColumn;
};
export type TableRows = Array<TableBodyRow>;
export type TableRowSortOrder = {
key: string;
direction: 'up' | 'down';
};
export type TableOnDragSelect = (
e: React.MouseEvent,
key: string,
index: number,
) => void;
export type TableOnAddFilter = (filter: Filter) => void;

View File

@@ -0,0 +1,33 @@
/**
* 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 function normalizeColumnWidth(
width: string | number | null | undefined,
): number | string {
if (width == null || width === 'flex') {
// 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`);
}
export function isPercentage(width: any): boolean {
return typeof width === 'string' && width[width.length - 1] === '%';
}

View File

@@ -0,0 +1,121 @@
/**
* 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 as styled} from '@emotion/styled';
export {default as Button} from './components/Button';
export {default as ToggleButton} from './components/ToggleSwitch';
export {default as ButtonGroup} from './components/ButtonGroup';
export {colors, darkColors, brandColors} from './components/colors';
export {default as Glyph, IconSize} from './components/Glyph';
export {default as LoadingIndicator} from './components/LoadingIndicator';
// tables
export {
TableColumns,
TableRows,
TableBodyColumn,
TableBodyRow,
TableHighlightedRows,
TableRowSortOrder,
TableColumnOrder,
TableColumnOrderVal,
TableColumnSizes,
} from './components/table/types';
export {default as ManagedTable} from './components/table/ManagedTable';
export {ManagedTableProps} from './components/table/ManagedTable';
export {DataValueExtractor, DataInspectorExpanded} from 'flipper-plugin';
export {DataInspector as ManagedDataInspector} from 'flipper-plugin';
// tabs
export {default as Tabs} from './components/Tabs';
export {default as Tab} from './components/Tab';
export {default as TabsContainer} from './components/TabsContainer';
// inputs
export {default as Input} from './components/Input';
export {default as MultiLineInput} from './components/MultiLineInput';
export {default as Textarea} from './components/Textarea';
export {default as Select} from './components/Select';
export {default as Checkbox} from './components/Checkbox';
export {default as Radio} from './components/Radio';
// error
export {default as ErrorBoundary} from './components/ErrorBoundary';
// interactive components
export {OrderableOrder} from './components/Orderable';
export {default as Orderable} from './components/Orderable';
// base components
export {Component, PureComponent} from 'react';
// context menus and dropdowns
export {default as ContextMenu} from './components/ContextMenu';
// file
export {FileListFile, FileListFiles} from './components/FileList';
export {default as FileList} from './components/FileList';
// utility elements
export {default as View} from './components/View';
export {default as Sidebar} from './components/Sidebar';
export {default as FlexBox} from './components/FlexBox';
export {default as FlexRow} from './components/FlexRow';
export {default as FlexColumn} from './components/FlexColumn';
export {default as FlexCenter} from './components/FlexCenter';
export {Spacer} from './components/Toolbar';
export {default as ToolbarIcon} from './components/ToolbarIcon';
export {default as Panel} from './components/Panel';
export {default as Text} from './components/Text';
export {default as Link} from './components/Link';
export {default as ModalOverlay} from './components/ModalOverlay';
export {default as Tooltip} from './components/Tooltip';
export {default as TooltipProvider} from './components/TooltipProvider';
export {default as StatusIndicator} from './components/StatusIndicator';
export {default as Line} from './components/Line';
// typography
export {default as HorizontalRule} from './components/HorizontalRule';
export {default as Label} from './components/Label';
export {default as Heading} from './components/Heading';
// filters
export {Filter} from './components/filter/types';
export {default as StackTrace} from './components/StackTrace';
export {
SearchBox,
SearchInput,
SearchIcon,
default as Searchable,
} from './components/searchable/Searchable';
export {
default as SearchableTable,
filterRowsFactory,
} from './components/searchable/SearchableTable';
export {SearchableProps} from './components/searchable/Searchable';
export {InspectorSidebar} from './components/elements-inspector/sidebar';
export {VisualizerPortal} from './components/elements-inspector/Visualizer';
export {Markdown} from './components/Markdown';
export {default as VBox} from './components/VBox';
export {default as HBox} from './components/HBox';
export {default as SmallText} from './components/SmallText';
export {default as Labeled} from './components/Labeled';
export {default as RoundedSection} from './components/RoundedSection';
export {default as CenteredView} from './components/CenteredView';
export {default as Info} from './components/Info';
export {Layout} from 'flipper-plugin';
export {default as Scrollable} from './components/Scrollable';