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:
committed by
Facebook GitHub Bot
parent
54b7ce9308
commit
7e50c0466a
148
desktop/flipper-ui-core/src/ui/components/Button.tsx
Normal file
148
desktop/flipper-ui-core/src/ui/components/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
desktop/flipper-ui-core/src/ui/components/ButtonGroup.tsx
Normal file
43
desktop/flipper-ui-core/src/ui/components/ButtonGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
desktop/flipper-ui-core/src/ui/components/CenteredView.tsx
Normal file
33
desktop/flipper-ui-core/src/ui/components/CenteredView.tsx
Normal 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;
|
||||
48
desktop/flipper-ui-core/src/ui/components/Checkbox.tsx
Normal file
48
desktop/flipper-ui-core/src/ui/components/Checkbox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
110
desktop/flipper-ui-core/src/ui/components/ContextMenu.tsx
Normal file
110
desktop/flipper-ui-core/src/ui/components/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
48
desktop/flipper-ui-core/src/ui/components/ErrorBlock.tsx
Normal file
48
desktop/flipper-ui-core/src/ui/components/ErrorBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
93
desktop/flipper-ui-core/src/ui/components/ErrorBoundary.tsx
Normal file
93
desktop/flipper-ui-core/src/ui/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
217
desktop/flipper-ui-core/src/ui/components/FileList.tsx
Normal file
217
desktop/flipper-ui-core/src/ui/components/FileList.tsx
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
119
desktop/flipper-ui-core/src/ui/components/FileSelector.tsx
Normal file
119
desktop/flipper-ui-core/src/ui/components/FileSelector.tsx
Normal 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;
|
||||
28
desktop/flipper-ui-core/src/ui/components/FlexBox.tsx
Normal file
28
desktop/flipper-ui-core/src/ui/components/FlexBox.tsx
Normal 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;
|
||||
24
desktop/flipper-ui-core/src/ui/components/FlexCenter.tsx
Normal file
24
desktop/flipper-ui-core/src/ui/components/FlexCenter.tsx
Normal 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;
|
||||
22
desktop/flipper-ui-core/src/ui/components/FlexColumn.tsx
Normal file
22
desktop/flipper-ui-core/src/ui/components/FlexColumn.tsx
Normal 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;
|
||||
22
desktop/flipper-ui-core/src/ui/components/FlexRow.tsx
Normal file
22
desktop/flipper-ui-core/src/ui/components/FlexRow.tsx
Normal 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;
|
||||
133
desktop/flipper-ui-core/src/ui/components/Glyph.tsx
Normal file
133
desktop/flipper-ui-core/src/ui/components/Glyph.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
87
desktop/flipper-ui-core/src/ui/components/HBox.tsx
Normal file
87
desktop/flipper-ui-core/src/ui/components/HBox.tsx
Normal 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;
|
||||
49
desktop/flipper-ui-core/src/ui/components/Heading.tsx
Normal file
49
desktop/flipper-ui-core/src/ui/components/Heading.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
19
desktop/flipper-ui-core/src/ui/components/HorizontalRule.tsx
Normal file
19
desktop/flipper-ui-core/src/ui/components/HorizontalRule.tsx
Normal 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;
|
||||
86
desktop/flipper-ui-core/src/ui/components/Info.tsx
Normal file
86
desktop/flipper-ui-core/src/ui/components/Info.tsx
Normal 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;
|
||||
54
desktop/flipper-ui-core/src/ui/components/Input.tsx
Normal file
54
desktop/flipper-ui-core/src/ui/components/Input.tsx
Normal 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;
|
||||
18
desktop/flipper-ui-core/src/ui/components/Label.tsx
Normal file
18
desktop/flipper-ui-core/src/ui/components/Label.tsx
Normal 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;
|
||||
28
desktop/flipper-ui-core/src/ui/components/Labeled.tsx
Normal file
28
desktop/flipper-ui-core/src/ui/components/Labeled.tsx
Normal 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;
|
||||
20
desktop/flipper-ui-core/src/ui/components/Line.tsx
Normal file
20
desktop/flipper-ui-core/src/ui/components/Line.tsx
Normal 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;
|
||||
21
desktop/flipper-ui-core/src/ui/components/Link.tsx
Normal file
21
desktop/flipper-ui-core/src/ui/components/Link.tsx
Normal 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;
|
||||
@@ -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;
|
||||
103
desktop/flipper-ui-core/src/ui/components/Markdown.tsx
Normal file
103
desktop/flipper-ui-core/src/ui/components/Markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
desktop/flipper-ui-core/src/ui/components/ModalOverlay.tsx
Normal file
53
desktop/flipper-ui-core/src/ui/components/ModalOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
36
desktop/flipper-ui-core/src/ui/components/MultiLineInput.tsx
Normal file
36
desktop/flipper-ui-core/src/ui/components/MultiLineInput.tsx
Normal 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;
|
||||
430
desktop/flipper-ui-core/src/ui/components/Orderable.tsx
Normal file
430
desktop/flipper-ui-core/src/ui/components/Orderable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
188
desktop/flipper-ui-core/src/ui/components/Panel.tsx
Normal file
188
desktop/flipper-ui-core/src/ui/components/Panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
48
desktop/flipper-ui-core/src/ui/components/Radio.tsx
Normal file
48
desktop/flipper-ui-core/src/ui/components/Radio.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
45
desktop/flipper-ui-core/src/ui/components/RequireLogin.tsx
Normal file
45
desktop/flipper-ui-core/src/ui/components/RequireLogin.tsx
Normal 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}</>;
|
||||
};
|
||||
45
desktop/flipper-ui-core/src/ui/components/RoundedSection.tsx
Normal file
45
desktop/flipper-ui-core/src/ui/components/RoundedSection.tsx
Normal 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;
|
||||
27
desktop/flipper-ui-core/src/ui/components/Scrollable.tsx
Normal file
27
desktop/flipper-ui-core/src/ui/components/Scrollable.tsx
Normal 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;
|
||||
106
desktop/flipper-ui-core/src/ui/components/Select.tsx
Normal file
106
desktop/flipper-ui-core/src/ui/components/Select.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
205
desktop/flipper-ui-core/src/ui/components/Sidebar.tsx
Normal file
205
desktop/flipper-ui-core/src/ui/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
26
desktop/flipper-ui-core/src/ui/components/SmallText.tsx
Normal file
26
desktop/flipper-ui-core/src/ui/components/SmallText.tsx
Normal 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;
|
||||
202
desktop/flipper-ui-core/src/ui/components/StackTrace.tsx
Normal file
202
desktop/flipper-ui-core/src/ui/components/StackTrace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
63
desktop/flipper-ui-core/src/ui/components/StarButton.tsx
Normal file
63
desktop/flipper-ui-core/src/ui/components/StarButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
48
desktop/flipper-ui-core/src/ui/components/Tab.tsx
Normal file
48
desktop/flipper-ui-core/src/ui/components/Tab.tsx
Normal 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");
|
||||
}
|
||||
336
desktop/flipper-ui-core/src/ui/components/Tabs.tsx
Normal file
336
desktop/flipper-ui-core/src/ui/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
desktop/flipper-ui-core/src/ui/components/TabsContainer.tsx
Normal file
32
desktop/flipper-ui-core/src/ui/components/TabsContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
desktop/flipper-ui-core/src/ui/components/Text.tsx
Normal file
56
desktop/flipper-ui-core/src/ui/components/Text.tsx
Normal 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;
|
||||
29
desktop/flipper-ui-core/src/ui/components/Textarea.tsx
Normal file
29
desktop/flipper-ui-core/src/ui/components/Textarea.tsx
Normal 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;
|
||||
109
desktop/flipper-ui-core/src/ui/components/ToggleSwitch.tsx
Normal file
109
desktop/flipper-ui-core/src/ui/components/ToggleSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
desktop/flipper-ui-core/src/ui/components/Toolbar.tsx
Normal file
19
desktop/flipper-ui-core/src/ui/components/Toolbar.tsx
Normal 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';
|
||||
49
desktop/flipper-ui-core/src/ui/components/ToolbarIcon.tsx
Normal file
49
desktop/flipper-ui-core/src/ui/components/ToolbarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
desktop/flipper-ui-core/src/ui/components/Tooltip.tsx
Normal file
67
desktop/flipper-ui-core/src/ui/components/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
desktop/flipper-ui-core/src/ui/components/TooltipProvider.tsx
Normal file
281
desktop/flipper-ui-core/src/ui/components/TooltipProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
desktop/flipper-ui-core/src/ui/components/VBox.tsx
Normal file
22
desktop/flipper-ui-core/src/ui/components/VBox.tsx
Normal 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;
|
||||
31
desktop/flipper-ui-core/src/ui/components/View.tsx
Normal file
31
desktop/flipper-ui-core/src/ui/components/View.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
303
desktop/flipper-ui-core/src/ui/components/colors.tsx
Normal file
303
desktop/flipper-ui-core/src/ui/components/colors.tsx
Normal 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
|
||||
};
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
26
desktop/flipper-ui-core/src/ui/components/filter/types.tsx
Normal file
26
desktop/flipper-ui-core/src/ui/components/filter/types.tsx
Normal 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;
|
||||
};
|
||||
@@ -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}>
|
||||
⌄
|
||||
</Chevron>
|
||||
</Token>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownTrigger = ['click' as const];
|
||||
@@ -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}>×</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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
753
desktop/flipper-ui-core/src/ui/components/table/ManagedTable.tsx
Normal file
753
desktop/flipper-ui-core/src/ui/components/table/ManagedTable.tsx
Normal 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});
|
||||
326
desktop/flipper-ui-core/src/ui/components/table/TableHead.tsx
Normal file
326
desktop/flipper-ui-core/src/ui/components/table/TableHead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
180
desktop/flipper-ui-core/src/ui/components/table/TableRow.tsx
Normal file
180
desktop/flipper-ui-core/src/ui/components/table/TableRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
90
desktop/flipper-ui-core/src/ui/components/table/types.tsx
Normal file
90
desktop/flipper-ui-core/src/ui/components/table/types.tsx
Normal 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;
|
||||
33
desktop/flipper-ui-core/src/ui/components/table/utils.tsx
Normal file
33
desktop/flipper-ui-core/src/ui/components/table/utils.tsx
Normal 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] === '%';
|
||||
}
|
||||
121
desktop/flipper-ui-core/src/ui/index.tsx
Normal file
121
desktop/flipper-ui-core/src/ui/index.tsx
Normal 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';
|
||||
Reference in New Issue
Block a user