Yarn workspaces
Summary: 1) moved "sonar/desktop/src" to "sonar/desktop/app/src", so "app" is now a separate package containing the core Flipper app code 2) Configured yarn workspaces with the root in "sonar/desktop": app, static, pkg, doctor, headless-tests. Plugins are not included for now, I plan to do this later. Reviewed By: jknoxville Differential Revision: D20535782 fbshipit-source-id: 600b2301960f37c7d72166e0d04eba462bec9fc1
This commit is contained in:
committed by
Facebook GitHub Bot
parent
676d7bbd24
commit
863f89351e
35
desktop/app/src/ui/components/AlternatingRows.tsx
Normal file
35
desktop/app/src/ui/components/AlternatingRows.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 Bordered from './Bordered';
|
||||
import {colors} from './colors';
|
||||
|
||||
/**
|
||||
* Displays all children in a bordered, zebra styled vertical layout
|
||||
*/
|
||||
const AlternatingRows: React.FC<{
|
||||
children: React.ReactNode[] | React.ReactNode;
|
||||
}> = ({children}) => (
|
||||
<Bordered style={{flexDirection: 'column'}}>
|
||||
{(Array.isArray(children) ? children : [children]).map((child, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: 8,
|
||||
background: idx % 2 === 0 ? colors.light02 : colors.white,
|
||||
}}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</Bordered>
|
||||
);
|
||||
|
||||
export default AlternatingRows;
|
||||
20
desktop/app/src/ui/components/Block.tsx
Normal file
20
desktop/app/src/ui/components/Block.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';
|
||||
|
||||
/**
|
||||
* A Block styled div
|
||||
*/
|
||||
const Block = styled.div({
|
||||
display: 'block',
|
||||
});
|
||||
Block.displayName = 'Block';
|
||||
|
||||
export default Block;
|
||||
25
desktop/app/src/ui/components/Bordered.tsx
Normal file
25
desktop/app/src/ui/components/Bordered.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Puts a gray border around something
|
||||
*/
|
||||
const Bordered = styled.div({
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
|
||||
backgroundColor: colors.white,
|
||||
display: 'flex',
|
||||
});
|
||||
Bordered.displayName = 'bordered';
|
||||
|
||||
export default Bordered;
|
||||
21
desktop/app/src/ui/components/Box.tsx
Normal file
21
desktop/app/src/ui/components/Box.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 FlexBox from './FlexBox';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const Box = styled(FlexBox)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
});
|
||||
Box.displayName = 'Box';
|
||||
|
||||
export default Box;
|
||||
392
desktop/app/src/ui/components/Button.tsx
Normal file
392
desktop/app/src/ui/components/Button.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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 PropTypes from 'prop-types';
|
||||
import electron, {MenuItemConstructorOptions} from 'electron';
|
||||
import styled from '@emotion/styled';
|
||||
import {colors} from './colors';
|
||||
import {connect} from 'react-redux';
|
||||
import {findDOMNode} from 'react-dom';
|
||||
import {keyframes} from 'emotion';
|
||||
import {State as Store} from '../../reducers/index';
|
||||
import Glyph, {IconSize} from './Glyph';
|
||||
|
||||
type ButtonType = 'primary' | 'success' | 'warning' | 'danger';
|
||||
|
||||
const borderColor = (props: {
|
||||
windowIsFocused?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: ButtonType;
|
||||
depressed?: boolean;
|
||||
}) => {
|
||||
if (!props.windowIsFocused) {
|
||||
return colors.macOSTitleBarButtonBorderBlur;
|
||||
} else if (props.type === 'danger' && !props.disabled) {
|
||||
return colors.red;
|
||||
} else if (props.type === 'primary' && !props.disabled) {
|
||||
return '#237FF1';
|
||||
} else if (props.depressed) {
|
||||
return colors.macOSTitleBarButtonBorderBottom;
|
||||
} else {
|
||||
return colors.macOSTitleBarButtonBorder;
|
||||
}
|
||||
};
|
||||
const borderBottomColor = (props: {
|
||||
windowIsFocused?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: ButtonType;
|
||||
depressed?: boolean;
|
||||
}) => {
|
||||
if (!props.windowIsFocused) {
|
||||
return colors.macOSTitleBarButtonBorderBlur;
|
||||
} else if (props.type === 'danger' && !props.disabled) {
|
||||
return colors.red;
|
||||
} else if (props.type === 'primary' && !props.disabled) {
|
||||
return '#237FF1';
|
||||
} else {
|
||||
return colors.macOSTitleBarButtonBorderBottom;
|
||||
}
|
||||
};
|
||||
|
||||
const backgroundImage = (props: {
|
||||
windowIsFocused?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: ButtonType;
|
||||
depressed?: boolean;
|
||||
}) => {
|
||||
if (props.windowIsFocused && !props.disabled) {
|
||||
if (props.depressed) {
|
||||
return `linear-gradient(to bottom, ${colors.macOSTitleBarBorderBlur} 1px, ${colors.macOSTitleBarButtonBorderBlur} 0%, ${colors.macOSTitleBarButtonBackgroundActive} 100%)`;
|
||||
} else if (props.type === 'primary') {
|
||||
return `linear-gradient(to bottom, #67a6f7 0%, #0072FA 100%)`;
|
||||
} else {
|
||||
return `linear-gradient(to bottom, transparent 0%,${colors.macOSTitleBarButtonBackground} 100%)`;
|
||||
}
|
||||
} else {
|
||||
return 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const color = (props: {
|
||||
windowIsFocused?: boolean;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
if (props.type === 'danger' && props.windowIsFocused && !props.disabled) {
|
||||
return colors.red;
|
||||
} else if (
|
||||
props.type === 'primary' &&
|
||||
props.windowIsFocused &&
|
||||
!props.disabled
|
||||
) {
|
||||
return colors.white;
|
||||
} else if (props.disabled) {
|
||||
return colors.macOSTitleBarIconBlur;
|
||||
} else {
|
||||
return colors.light50;
|
||||
}
|
||||
};
|
||||
|
||||
const pulse = keyframes({
|
||||
'0%': {
|
||||
boxShadow: `0 0 4px 0 ${colors.macOSTitleBarIconSelected}`,
|
||||
},
|
||||
'70%': {
|
||||
boxShadow: '0 0 4px 6px transparent',
|
||||
},
|
||||
'100%': {
|
||||
boxShadow: '0 0 4px 0 transparent',
|
||||
},
|
||||
});
|
||||
|
||||
const StyledButton = styled.div<{
|
||||
windowIsFocused?: boolean;
|
||||
compact?: boolean;
|
||||
inButtonGroup?: boolean;
|
||||
padded?: boolean;
|
||||
pulse?: boolean;
|
||||
disabled?: boolean;
|
||||
dropdown?: Array<MenuItemConstructorOptions>;
|
||||
}>(props => ({
|
||||
backgroundColor:
|
||||
props.windowIsFocused && !props.disabled
|
||||
? colors.white
|
||||
: colors.macOSTitleBarButtonBackgroundBlur,
|
||||
backgroundImage: backgroundImage(props),
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
borderColor: borderColor(props),
|
||||
borderBottomColor: borderBottomColor(props),
|
||||
color: color(props),
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
padding: props.padded ? '0 15px' : '0 6px',
|
||||
height: props.compact === true ? 24 : 28,
|
||||
margin: 0,
|
||||
minWidth: 34,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
|
||||
boxShadow:
|
||||
props.pulse && props.windowIsFocused
|
||||
? `0 0 0 ${colors.macOSTitleBarIconSelected}`
|
||||
: '',
|
||||
animation: props.pulse && props.windowIsFocused ? `${pulse} 1s infinite` : '',
|
||||
|
||||
'&:not(:first-child)': {
|
||||
marginLeft: props.inButtonGroup === true ? 0 : 10,
|
||||
borderTopLeftRadius: props.inButtonGroup === true ? 0 : 4,
|
||||
borderBottomLeftRadius: props.inButtonGroup === true ? 0 : 4,
|
||||
},
|
||||
|
||||
'&:not(:last-child)': {
|
||||
borderTopRightRadius: props.inButtonGroup === true ? 0 : 4,
|
||||
borderBottomRightRadius: props.inButtonGroup === true ? 0 : 4,
|
||||
borderRight: props.inButtonGroup === true ? 0 : '',
|
||||
},
|
||||
|
||||
'&:first-of-type': {
|
||||
marginLeft: 0,
|
||||
},
|
||||
|
||||
'&:active': props.disabled
|
||||
? null
|
||||
: {
|
||||
borderColor: colors.macOSTitleBarButtonBorder,
|
||||
borderBottomColor: colors.macOSTitleBarButtonBorderBottom,
|
||||
background: `linear-gradient(to bottom, ${colors.macOSTitleBarButtonBackgroundActiveHighlight} 1px, ${colors.macOSTitleBarButtonBackgroundActive} 0%, ${colors.macOSTitleBarButtonBorderBlur} 100%)`,
|
||||
},
|
||||
|
||||
'&:disabled': {
|
||||
borderColor: borderColor(props),
|
||||
borderBottomColor: borderBottomColor(props),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
|
||||
'&:hover::before': {
|
||||
content: props.dropdown ? "''" : 'normal',
|
||||
position: 'absolute',
|
||||
bottom: 1,
|
||||
right: 2,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '4px 3px 0 3px',
|
||||
borderColor: `${colors.macOSTitleBarIcon} transparent transparent transparent`,
|
||||
},
|
||||
}));
|
||||
StyledButton.displayName = 'Button:StyledButton';
|
||||
|
||||
const Icon = styled(Glyph)<{hasText: boolean}>(({hasText}) => ({
|
||||
marginRight: hasText ? 3 : 0,
|
||||
}));
|
||||
Icon.displayName = 'Button:Icon';
|
||||
|
||||
type OwnProps = {
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Children.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Dropdown menu template shown on click.
|
||||
*/
|
||||
dropdown?: Array<MenuItemConstructorOptions>;
|
||||
/**
|
||||
* 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;
|
||||
} & React.HTMLProps<HTMLDivElement>;
|
||||
|
||||
type State = {
|
||||
active: boolean;
|
||||
wasClosed: boolean;
|
||||
};
|
||||
|
||||
type StateFromProps = {windowIsFocused: boolean};
|
||||
type Props = OwnProps & StateFromProps;
|
||||
|
||||
/**
|
||||
* A simple button, used in many parts of the application.
|
||||
*/
|
||||
class Button extends React.Component<Props, State> {
|
||||
static contextTypes = {
|
||||
inButtonGroup: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
wasClosed: false,
|
||||
};
|
||||
|
||||
_ref = React.createRef<React.Component<typeof StyledButton>>();
|
||||
|
||||
onMouseDown = (e: React.MouseEvent) => {
|
||||
this.setState({active: true, wasClosed: false});
|
||||
if (this.props.onMouseDown != null) {
|
||||
this.props.onMouseDown(e);
|
||||
}
|
||||
};
|
||||
onMouseUp = () => {
|
||||
if (this.props.disabled === true) {
|
||||
return;
|
||||
}
|
||||
if (this.props.dropdown && !this.state.wasClosed) {
|
||||
const menu = electron.remote.Menu.buildFromTemplate(this.props.dropdown);
|
||||
const position: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
} = {};
|
||||
const {current} = this._ref;
|
||||
if (current) {
|
||||
const node = findDOMNode(current);
|
||||
if (node instanceof Element) {
|
||||
const {left, bottom} = node.getBoundingClientRect();
|
||||
position.x = Math.floor(left);
|
||||
position.y = Math.floor(bottom) + 6;
|
||||
}
|
||||
}
|
||||
menu.popup({
|
||||
window: electron.remote.getCurrentWindow(),
|
||||
// @ts-ignore: async is private API in electron
|
||||
async: true,
|
||||
...position,
|
||||
callback: () => {
|
||||
this.setState({wasClosed: true});
|
||||
},
|
||||
});
|
||||
}
|
||||
this.setState({active: false, wasClosed: false});
|
||||
};
|
||||
|
||||
onClick = (e: React.MouseEvent) => {
|
||||
if (this.props.disabled === true) {
|
||||
return;
|
||||
}
|
||||
if (this.props.onClick) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
if (this.props.href != null) {
|
||||
electron.shell.openExternal(this.props.href);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
icon,
|
||||
children,
|
||||
selected,
|
||||
iconSize,
|
||||
windowIsFocused,
|
||||
iconVariant,
|
||||
...props
|
||||
} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
let color = colors.macOSTitleBarIcon;
|
||||
if (props.disabled === true) {
|
||||
color = colors.macOSTitleBarIconBlur;
|
||||
} else if (windowIsFocused && selected === true) {
|
||||
color = colors.macOSTitleBarIconSelected;
|
||||
} else if (!windowIsFocused && (selected == null || selected === false)) {
|
||||
color = colors.macOSTitleBarIconBlur;
|
||||
} else if (!windowIsFocused && selected === true) {
|
||||
color = colors.macOSTitleBarIconSelectedBlur;
|
||||
} else if (selected == null && active) {
|
||||
color = colors.macOSTitleBarIconActive;
|
||||
} else if (props.type === 'danger') {
|
||||
color = colors.red;
|
||||
}
|
||||
|
||||
let iconComponent;
|
||||
if (icon != null) {
|
||||
iconComponent = (
|
||||
<Icon
|
||||
name={icon}
|
||||
size={iconSize || (this.props.compact === true ? 12 : 16)}
|
||||
color={color}
|
||||
variant={iconVariant || 'filled'}
|
||||
hasText={Boolean(children)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
{...props}
|
||||
ref={this._ref as any}
|
||||
windowIsFocused={windowIsFocused}
|
||||
onClick={this.onClick}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onMouseUp={this.onMouseUp}
|
||||
inButtonGroup={this.context.inButtonGroup}>
|
||||
{iconComponent}
|
||||
{children}
|
||||
</StyledButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateFromProps, {}, OwnProps, Store>(
|
||||
({application: {windowIsFocused}}) => ({
|
||||
windowIsFocused,
|
||||
}),
|
||||
)(Button);
|
||||
48
desktop/app/src/ui/components/ButtonGroup.tsx
Normal file
48
desktop/app/src/ui/components/ButtonGroup.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, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ButtonGroupContainer = styled.div({
|
||||
display: 'inline-flex',
|
||||
marginLeft: 10,
|
||||
'&:first-child': {
|
||||
marginLeft: 0,
|
||||
},
|
||||
});
|
||||
ButtonGroupContainer.displayName = 'ButtonGroup:ButtonGroupContainer';
|
||||
|
||||
/**
|
||||
* Group a series of buttons together.
|
||||
*
|
||||
* ```jsx
|
||||
* <ButtonGroup>
|
||||
* <Button>One</Button>
|
||||
* <Button>Two</Button>
|
||||
* <Button>Three</Button>
|
||||
* </ButtonGroup>
|
||||
* ```
|
||||
*/
|
||||
export default class ButtonGroup extends Component<{
|
||||
children: React.ReactNode;
|
||||
}> {
|
||||
static childContextTypes = {
|
||||
inButtonGroup: PropTypes.bool,
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return {inButtonGroup: true};
|
||||
}
|
||||
|
||||
render() {
|
||||
return <ButtonGroupContainer>{this.props.children}</ButtonGroupContainer>;
|
||||
}
|
||||
}
|
||||
101
desktop/app/src/ui/components/ButtonGroupChain.tsx
Normal file
101
desktop/app/src/ui/components/ButtonGroupChain.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
import Glyph from './Glyph';
|
||||
|
||||
const IconContainer = styled.div({
|
||||
width: 0,
|
||||
zIndex: 1,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
IconContainer.displayName = 'ButtonGroupChain:IconContainer';
|
||||
|
||||
const ButtonGroupChainContainer = styled.div<{iconSize: number}>(props => ({
|
||||
display: 'inline-flex',
|
||||
marginLeft: 10,
|
||||
'&:first-child>*:not(:first-child):nth-child(odd)': {
|
||||
paddingLeft: props.iconSize + 6,
|
||||
},
|
||||
'&:first-child>*': {
|
||||
borderRightStyle: 'none',
|
||||
borderLeftStyle: 'none',
|
||||
},
|
||||
'&:first-child>:first-child': {
|
||||
borderLeftStyle: 'solid',
|
||||
},
|
||||
'&:first-child>:last-child': {
|
||||
borderRightStyle: 'solid',
|
||||
},
|
||||
}));
|
||||
IconContainer.displayName = 'ButtonGroupChain:ButtonGroupChainContainer';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Children.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Size of the button seperator icon in pixels.
|
||||
*/
|
||||
iconSize: 8 | 10 | 12 | 16 | 18 | 20 | 24 | 32;
|
||||
/**
|
||||
* Name of the icon seperating the buttons. Defaults to 'chevron-right'.
|
||||
*/
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups a series of buttons together with
|
||||
* a right-chevron icon to seperate them.
|
||||
* Used to create a navigation heirarchy.
|
||||
*
|
||||
* ```jsx
|
||||
* <ButtonGroupChain iconSize={12}>
|
||||
* <Button>One</Button>
|
||||
* <Button>Two</Button>
|
||||
* <Button>Three</Button>
|
||||
* </ButtonGroupChain>
|
||||
* ```
|
||||
*/
|
||||
export default class ButtonGroupChain extends Component<Props> {
|
||||
static childContextTypes = {
|
||||
inButtonGroup: PropTypes.bool,
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return {inButtonGroup: true};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children, iconSize, icon} = this.props;
|
||||
|
||||
return (
|
||||
<ButtonGroupChainContainer iconSize={iconSize}>
|
||||
{React.Children.map(children, (child, idx) => {
|
||||
if (idx === 0) {
|
||||
return child;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<IconContainer>
|
||||
<Glyph name={icon || 'chevron-right'} size={iconSize} />
|
||||
</IconContainer>
|
||||
{child}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</ButtonGroupChainContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
38
desktop/app/src/ui/components/ButtonNavigationGroup.tsx
Normal file
38
desktop/app/src/ui/components/ButtonNavigationGroup.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 ButtonGroup from './ButtonGroup';
|
||||
import Button from './Button';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Button group to navigate back and forth.
|
||||
*/
|
||||
export default function ButtonNavigationGroup(props: {
|
||||
/** Back button is enabled */
|
||||
canGoBack: boolean;
|
||||
/** Forwards button is enabled */
|
||||
canGoForward: boolean;
|
||||
/** Callback when back button is clicked */
|
||||
onBack: () => void;
|
||||
/** Callback when forwards button is clicked */
|
||||
onForward: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button disabled={!props.canGoBack} onClick={props.onBack}>
|
||||
{'<'}
|
||||
</Button>
|
||||
|
||||
<Button disabled={!props.canGoForward} onClick={props.onForward}>
|
||||
{'<'}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
40
desktop/app/src/ui/components/CenteredView.tsx
Normal file
40
desktop/app/src/ui/components/CenteredView.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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 {colors} from './colors';
|
||||
import FlexColumn from './FlexColumn';
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
backgroundColor: colors.light02,
|
||||
});
|
||||
Container.displayName = 'CenteredView:Container';
|
||||
|
||||
const ContentWrapper = styled.div({
|
||||
width: 500,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
padding: '20px 0',
|
||||
});
|
||||
ContentWrapper.displayName = 'CenteredView:ContentWrapper';
|
||||
|
||||
/**
|
||||
* CenteredView creates a scrollable container with fixed with, centered content.
|
||||
* Recommended to combine with RoundedSection
|
||||
*/
|
||||
const CenteredView: React.FC<{}> = ({children}) => (
|
||||
<Container grow>
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</Container>
|
||||
);
|
||||
|
||||
export default CenteredView;
|
||||
47
desktop/app/src/ui/components/Checkbox.tsx
Normal file
47
desktop/app/src/ui/components/Checkbox.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
desktop/app/src/ui/components/CodeBlock.tsx
Normal file
17
desktop/app/src/ui/components/CodeBlock.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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 CodeBlock = styled.div({
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
CodeBlock.displayName = 'CodeBlock';
|
||||
|
||||
export default CodeBlock;
|
||||
59
desktop/app/src/ui/components/ContextMenu.tsx
Normal file
59
desktop/app/src/ui/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 {createElement, useContext, useCallback} from 'react';
|
||||
import {ContextMenuContext} from './ContextMenuProvider';
|
||||
import FlexColumn from './FlexColumn';
|
||||
import {MenuItemConstructorOptions} from 'electron';
|
||||
|
||||
export type MenuTemplate = Array<MenuItemConstructorOptions>;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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'}`
|
||||
*/
|
||||
export default function ContextMenu<C>({
|
||||
items,
|
||||
buildItems,
|
||||
component,
|
||||
children,
|
||||
...otherProps
|
||||
}: Props<C>) {
|
||||
const contextMenuManager = useContext(ContextMenuContext);
|
||||
const onContextMenu = useCallback(() => {
|
||||
if (items != null) {
|
||||
contextMenuManager?.appendToContextMenu(items);
|
||||
} else if (buildItems != null) {
|
||||
contextMenuManager?.appendToContextMenu(buildItems());
|
||||
}
|
||||
}, []);
|
||||
return createElement(
|
||||
component || FlexColumn,
|
||||
{
|
||||
onContextMenu,
|
||||
...otherProps,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
56
desktop/app/src/ui/components/ContextMenuProvider.tsx
Normal file
56
desktop/app/src/ui/components/ContextMenuProvider.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 electron, {MenuItemConstructorOptions} from 'electron';
|
||||
import React, {useRef, memo, createContext, useMemo, useCallback} from 'react';
|
||||
|
||||
type MenuTemplate = Array<MenuItemConstructorOptions>;
|
||||
interface ContextMenuManager {
|
||||
appendToContextMenu(items: MenuTemplate): void;
|
||||
}
|
||||
|
||||
const Container = styled.div({
|
||||
display: 'contents',
|
||||
});
|
||||
Container.displayName = 'ContextMenuProvider:Container';
|
||||
|
||||
export const ContextMenuContext = createContext<ContextMenuManager | undefined>(
|
||||
undefined,
|
||||
);
|
||||
/**
|
||||
* Flipper's root is already wrapped with this component, so plugins should not
|
||||
* need to use this. ContextMenu is what you probably want to use.
|
||||
*/
|
||||
const ContextMenuProvider: React.FC<{}> = memo(function ContextMenuProvider({
|
||||
children,
|
||||
}) {
|
||||
const menuTemplate = useRef<MenuTemplate>([]);
|
||||
const contextMenuManager = useMemo(
|
||||
() => ({
|
||||
appendToContextMenu(items: MenuTemplate) {
|
||||
menuTemplate.current = menuTemplate.current.concat(items);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const onContextMenu = useCallback(() => {
|
||||
const menu = electron.remote.Menu.buildFromTemplate(menuTemplate.current);
|
||||
menuTemplate.current = [];
|
||||
menu.popup({window: electron.remote.getCurrentWindow()});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ContextMenuContext.Provider value={contextMenuManager}>
|
||||
<Container onContextMenu={onContextMenu}>{children}</Container>
|
||||
</ContextMenuContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
export default ContextMenuProvider;
|
||||
48
desktop/app/src/ui/components/ErrorBlock.tsx
Normal file
48
desktop/app/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 './CodeBlock';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
91
desktop/app/src/ui/components/ErrorBoundary.tsx
Normal file
91
desktop/app/src/ui/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 ErrorBlock from './ErrorBlock';
|
||||
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(ErrorBlock)({
|
||||
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={true}>
|
||||
<Heading>{heading}</Heading>
|
||||
{this.props.showStack !== false && (
|
||||
<ErrorBoundaryStack error={error} />
|
||||
)}
|
||||
<Button onClick={this.clearError}>Clear error and try again</Button>
|
||||
</ErrorBoundaryContainer>
|
||||
);
|
||||
} else {
|
||||
return this.props.children || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
desktop/app/src/ui/components/File.tsx
Normal file
93
desktop/app/src/ui/components/File.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 React, {Component} from 'react';
|
||||
import fs from 'fs';
|
||||
|
||||
type FileProps = {
|
||||
/** Path to the file in the file system */
|
||||
src: string;
|
||||
/** Initial content that should be shown while the file is loading */
|
||||
buffer?: string | null | undefined;
|
||||
/** Encoding to parse the contents of the file. Defaults to UTF-8. */
|
||||
encoding: string;
|
||||
/** Content that should be rendered, when the file loading failed. */
|
||||
onError?: (err: Error) => React.ReactNode;
|
||||
/** Content that should be rendered, while the file is loaded. */
|
||||
onLoading?: () => React.ReactNode;
|
||||
/** Callback when the data is successfully loaded. */
|
||||
onData?: (content: string) => void;
|
||||
/** Content that should be rendered, when the file is successfully loaded. This ususally should render the file's contents. */
|
||||
onLoad: (content: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
type FileState = {
|
||||
error: Error | null | undefined;
|
||||
loaded: boolean;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for loading file content from the file system.
|
||||
*/
|
||||
export default class File extends Component<FileProps, FileState> {
|
||||
constructor(props: FileProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
content: props.buffer || '',
|
||||
error: null,
|
||||
loaded: props.buffer != null,
|
||||
};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
encoding: 'utf8',
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(nextProps: FileProps) {
|
||||
if (nextProps.buffer != null) {
|
||||
return {content: nextProps.buffer, loaded: true};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.state.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readFile(this.props.src, this.props.encoding, (err, content) => {
|
||||
if (err) {
|
||||
this.setState({error: err});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({content, loaded: true});
|
||||
|
||||
if (this.props.onData) {
|
||||
this.props.onData(content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {onError, onLoad, onLoading} = this.props;
|
||||
const {content, error, loaded} = this.state;
|
||||
|
||||
if (error && onError) {
|
||||
return onError(error);
|
||||
} else if (loaded) {
|
||||
return onLoad(content);
|
||||
} else if (onLoading) {
|
||||
return onLoading();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
184
desktop/app/src/ui/components/FileList.tsx
Normal file
184
desktop/app/src/ui/components/FileList.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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(name: string): Promise<FileListFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const loc = path.join(this.props.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetchFiles(callback?: Function) {
|
||||
const {src} = this.props;
|
||||
|
||||
const setState = (data: FileListState) => {
|
||||
if (!hasChangedDir()) {
|
||||
this.setState(data);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChangedDir = () => this.props.src !== src;
|
||||
|
||||
fs.readdir(src, (err, files) => {
|
||||
if (err) {
|
||||
setState({error: err, files: EMPTY_MAP});
|
||||
return;
|
||||
}
|
||||
|
||||
const filesSet: Map<string, FileListFile> = new Map();
|
||||
const next = () => {
|
||||
if (hasChangedDir()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
setState({error: null, files: filesSet});
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const name = files.shift();
|
||||
if (name) {
|
||||
this.fetchFile(name)
|
||||
.then(data => {
|
||||
filesSet.set(name, data);
|
||||
next();
|
||||
})
|
||||
.catch(err => {
|
||||
setState({error: err, files: EMPTY_MAP});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
135
desktop/app/src/ui/components/FileSelector.tsx
Normal file
135
desktop/app/src/ui/components/FileSelector.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 electron from 'electron';
|
||||
import styled from '@emotion/styled';
|
||||
import {colors} from './colors';
|
||||
import Electron from 'electron';
|
||||
import fs from 'fs';
|
||||
import {Tooltip} from '..';
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
function strToArr<T extends string>(item: T): T[] {
|
||||
return [item];
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
onPathChanged: (evtArgs: {path: string; isValid: boolean}) => void;
|
||||
placeholderText: string;
|
||||
defaultPath: string;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const defaultProps: Props = {
|
||||
onPathChanged: _ => {},
|
||||
placeholderText: '',
|
||||
defaultPath: '/',
|
||||
showHiddenFiles: false,
|
||||
};
|
||||
|
||||
export default function FileSelector({
|
||||
onPathChanged,
|
||||
placeholderText,
|
||||
|
||||
defaultPath,
|
||||
showHiddenFiles,
|
||||
}: Props) {
|
||||
const [value, setValue] = useState('');
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const options: Electron.OpenDialogOptions = {
|
||||
properties: [
|
||||
'openFile',
|
||||
...(showHiddenFiles ? strToArr('showHiddenFiles') : []),
|
||||
],
|
||||
defaultPath,
|
||||
};
|
||||
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={true}
|
||||
onDrop={e => {
|
||||
if (e.dataTransfer.files.length) {
|
||||
onChange(e.dataTransfer.files[0].path);
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<GlyphContainer
|
||||
onClick={() =>
|
||||
electron.remote.dialog
|
||||
.showOpenDialog(options)
|
||||
.then((result: electron.OpenDialogReturnValue) => {
|
||||
if (result && !result.canceled && result.filePaths.length) {
|
||||
onChange(result.filePaths[0]);
|
||||
}
|
||||
})
|
||||
}>
|
||||
<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;
|
||||
27
desktop/app/src/ui/components/FlexBox.tsx
Normal file
27
desktop/app/src/ui/components/FlexBox.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 View from './View';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type Props = {
|
||||
/** Flexbox's shrink property. Set to `0`, to disable shrinking. */
|
||||
shrink?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
23
desktop/app/src/ui/components/FlexCenter.tsx
Normal file
23
desktop/app/src/ui/components/FlexCenter.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import View from './View';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
21
desktop/app/src/ui/components/FlexColumn.tsx
Normal file
21
desktop/app/src/ui/components/FlexColumn.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 FlexBox from './FlexBox';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
/**
|
||||
* A container displaying its children in a column
|
||||
*/
|
||||
const FlexColumn = styled(FlexBox)({
|
||||
flexDirection: 'column',
|
||||
});
|
||||
FlexColumn.displayName = 'FlexColumn';
|
||||
|
||||
export default FlexColumn;
|
||||
21
desktop/app/src/ui/components/FlexRow.tsx
Normal file
21
desktop/app/src/ui/components/FlexRow.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 FlexBox from './FlexBox';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
/**
|
||||
* A container displaying its children in a row
|
||||
*/
|
||||
const FlexRow = styled(FlexBox)({
|
||||
flexDirection: 'row',
|
||||
});
|
||||
FlexRow.displayName = 'FlexRow';
|
||||
|
||||
export default FlexRow;
|
||||
78
desktop/app/src/ui/components/FocusableBox.tsx
Normal file
78
desktop/app/src/ui/components/FocusableBox.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 Box from './Box';
|
||||
import {colors} from './colors';
|
||||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
|
||||
const FocusableBoxBorder = styled(Box)({
|
||||
border: `1px solid ${colors.highlight}`,
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: '0',
|
||||
top: '0',
|
||||
});
|
||||
FocusableBoxBorder.displayName = 'FocusableBox:FocusableBoxBorder';
|
||||
|
||||
type Props = {
|
||||
onBlur?: (e: React.FocusEvent) => void;
|
||||
onFocus?: (e: React.FocusEvent) => void;
|
||||
focusable?: boolean;
|
||||
};
|
||||
|
||||
export default class FocusableBox extends Component<
|
||||
Props,
|
||||
{
|
||||
focused: boolean;
|
||||
}
|
||||
> {
|
||||
constructor(props: Props, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {focused: false};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
focusable: true,
|
||||
};
|
||||
|
||||
onBlur = (e: React.FocusEvent) => {
|
||||
const {onBlur} = this.props;
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
if (this.state.focused) {
|
||||
this.setState({focused: false});
|
||||
}
|
||||
};
|
||||
|
||||
onFocus = (e: React.FocusEvent) => {
|
||||
const {onFocus} = this.props;
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
if (this.props.focusable) {
|
||||
this.setState({focused: true});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return (
|
||||
<Box {...props} onFocus={this.onFocus} onBlur={this.onBlur} tabIndex={0}>
|
||||
{props.children}
|
||||
{this.state.focused && <FocusableBoxBorder />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
137
desktop/app/src/ui/components/Glyph.tsx
Normal file
137
desktop/app/src/ui/components/Glyph.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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 PropTypes from 'prop-types';
|
||||
import {getIconURL} 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';
|
||||
ColoredIcon.contextTypes = {
|
||||
glyphColor: PropTypes.string,
|
||||
};
|
||||
|
||||
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={getIconURL(
|
||||
variant === 'outline' ? `${name}-outline` : name,
|
||||
size,
|
||||
typeof window !== 'undefined' ? window.devicePixelRatio : 1,
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
84
desktop/app/src/ui/components/HBox.tsx
Normal file
84
desktop/app/src/ui/components/HBox.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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/app/src/ui/components/Heading.tsx
Normal file
49
desktop/app/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/app/src/ui/components/HorizontalRule.tsx
Normal file
19
desktop/app/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/app/src/ui/components/Info.tsx
Normal file
86
desktop/app/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;
|
||||
52
desktop/app/src/ui/components/Input.tsx
Normal file
52
desktop/app/src/ui/components/Input.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const inputStyle = (props: {
|
||||
compact: boolean;
|
||||
valid: boolean;
|
||||
readOnly: boolean;
|
||||
}) => ({
|
||||
border: `1px solid ${props.valid ? colors.light15 : colors.red}`,
|
||||
borderRadius: 4,
|
||||
font: 'inherit',
|
||||
fontSize: '1em',
|
||||
|
||||
height: props.compact ? '17px' : '28px',
|
||||
lineHeight: props.compact ? '17px' : '28px',
|
||||
backgroundColor: props.readOnly ? colors.light02 : undefined,
|
||||
'&:disabled': {
|
||||
backgroundColor: '#ddd',
|
||||
borderColor: '#ccc',
|
||||
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;
|
||||
719
desktop/app/src/ui/components/Interactive.tsx
Normal file
719
desktop/app/src/ui/components/Interactive.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* 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 LowPassFilter from '../../utils/LowPassFilter';
|
||||
import {
|
||||
getDistanceTo,
|
||||
maybeSnapLeft,
|
||||
maybeSnapTop,
|
||||
SNAP_SIZE,
|
||||
} from '../../utils/snap';
|
||||
import styled from '@emotion/styled';
|
||||
import invariant from 'invariant';
|
||||
import React from 'react';
|
||||
|
||||
const WINDOW_CURSOR_BOUNDARY = 5;
|
||||
|
||||
type CursorState = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
type ResizingSides =
|
||||
| {
|
||||
left?: boolean;
|
||||
top?: boolean;
|
||||
bottom?: boolean;
|
||||
right?: boolean;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const ALL_RESIZABLE: ResizingSides = {
|
||||
bottom: true,
|
||||
left: true,
|
||||
right: true,
|
||||
top: true,
|
||||
};
|
||||
|
||||
type InteractiveProps = {
|
||||
isMovableAnchor?: (event: React.MouseEvent) => boolean;
|
||||
onMoveStart?: () => void;
|
||||
onMoveEnd?: () => void;
|
||||
onMove?: (top: number, left: number, event: MouseEvent) => void;
|
||||
id?: string;
|
||||
movable?: boolean;
|
||||
hidden?: boolean;
|
||||
moving?: boolean;
|
||||
grow?: boolean;
|
||||
siblings?: Partial<{[key: string]: Rect}>;
|
||||
updateCursor?: (cursor?: string | null | undefined) => void;
|
||||
zIndex?: number;
|
||||
top?: number;
|
||||
left?: number;
|
||||
minTop?: number;
|
||||
minLeft?: number;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
onCanResize?: (sides: ResizingSides) => void;
|
||||
onResizeStart?: () => void;
|
||||
onResizeEnd?: () => void;
|
||||
onResize?: (width: number, height: number) => void;
|
||||
resizing?: boolean;
|
||||
resizable?: boolean | ResizingSides;
|
||||
innerRef?: (elem: HTMLElement | null) => void;
|
||||
style?: Object;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type InteractiveState = {
|
||||
moving: boolean;
|
||||
movingInitialProps: InteractiveProps | null | undefined;
|
||||
movingInitialCursor: CursorState | null | undefined;
|
||||
cursor: string | undefined;
|
||||
resizingSides: ResizingSides;
|
||||
couldResize: boolean;
|
||||
resizing: boolean;
|
||||
resizingInitialRect: Rect | null | undefined;
|
||||
resizingInitialCursor: CursorState | null | undefined;
|
||||
};
|
||||
|
||||
const InteractiveContainer = styled.div({
|
||||
willChange: 'transform, height, width, z-index',
|
||||
});
|
||||
InteractiveContainer.displayName = 'Interactive:InteractiveContainer';
|
||||
|
||||
export default class Interactive extends React.Component<
|
||||
InteractiveProps,
|
||||
InteractiveState
|
||||
> {
|
||||
constructor(props: InteractiveProps, context: Object) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
couldResize: false,
|
||||
cursor: undefined,
|
||||
moving: false,
|
||||
movingInitialCursor: null,
|
||||
movingInitialProps: null,
|
||||
resizing: false,
|
||||
resizingInitialCursor: null,
|
||||
resizingInitialRect: null,
|
||||
resizingSides: null,
|
||||
};
|
||||
|
||||
this.globalMouse = false;
|
||||
}
|
||||
|
||||
globalMouse: boolean;
|
||||
ref?: HTMLElement | null;
|
||||
|
||||
nextTop?: number | null;
|
||||
nextLeft?: number | null;
|
||||
nextEvent?: MouseEvent | null;
|
||||
|
||||
static defaultProps = {
|
||||
minHeight: 0,
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
onMouseMove = (event: MouseEvent) => {
|
||||
if (this.state.moving) {
|
||||
this.calculateMove(event);
|
||||
} else if (this.state.resizing) {
|
||||
this.calculateResize(event);
|
||||
} else {
|
||||
this.calculateResizable(event);
|
||||
}
|
||||
};
|
||||
|
||||
startAction = (event: React.MouseEvent) => {
|
||||
this.globalMouse = true;
|
||||
window.addEventListener('pointerup', this.endAction, {passive: true});
|
||||
window.addEventListener('pointermove', this.onMouseMove, {passive: true});
|
||||
|
||||
const {isMovableAnchor} = this.props;
|
||||
if (isMovableAnchor && isMovableAnchor(event)) {
|
||||
this.startTitleAction(event);
|
||||
} else {
|
||||
this.startWindowAction(event);
|
||||
}
|
||||
};
|
||||
|
||||
startTitleAction(event: React.MouseEvent) {
|
||||
if (this.state.couldResize) {
|
||||
this.startResizeAction(event);
|
||||
} else if (this.props.movable === true) {
|
||||
this.startMoving(event);
|
||||
}
|
||||
}
|
||||
|
||||
startMoving(event: React.MouseEvent) {
|
||||
const {onMoveStart} = this.props;
|
||||
if (onMoveStart) {
|
||||
onMoveStart();
|
||||
}
|
||||
|
||||
if (this.context.os) {
|
||||
// pause OS timers to avoid lag when dragging
|
||||
this.context.os.timers.pause();
|
||||
}
|
||||
|
||||
const topLpf = new LowPassFilter();
|
||||
const leftLpf = new LowPassFilter();
|
||||
|
||||
this.nextTop = null;
|
||||
this.nextLeft = null;
|
||||
this.nextEvent = null;
|
||||
|
||||
const onFrame = () => {
|
||||
if (!this.state.moving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {nextEvent, nextTop, nextLeft} = this;
|
||||
if (nextEvent && nextTop != null && nextLeft != null) {
|
||||
if (topLpf.hasFullBuffer()) {
|
||||
const newTop = topLpf.next(nextTop);
|
||||
const newLeft = leftLpf.next(nextLeft);
|
||||
this.move(newTop, newLeft, nextEvent);
|
||||
} else {
|
||||
this.move(nextTop, nextLeft, nextEvent);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(onFrame);
|
||||
};
|
||||
|
||||
this.setState(
|
||||
{
|
||||
cursor: 'move',
|
||||
moving: true,
|
||||
movingInitialCursor: {
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
},
|
||||
movingInitialProps: this.props,
|
||||
},
|
||||
onFrame,
|
||||
);
|
||||
}
|
||||
|
||||
getPossibleTargetWindows(rect: Rect) {
|
||||
const closeWindows = [];
|
||||
|
||||
const {siblings} = this.props;
|
||||
if (siblings) {
|
||||
for (const key in siblings) {
|
||||
if (key === this.props.id) {
|
||||
// don't target ourselves
|
||||
continue;
|
||||
}
|
||||
|
||||
const win = siblings[key];
|
||||
if (win) {
|
||||
const distance = getDistanceTo(rect, win);
|
||||
if (distance <= SNAP_SIZE) {
|
||||
closeWindows.push(win);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closeWindows;
|
||||
}
|
||||
|
||||
startWindowAction(event: React.MouseEvent) {
|
||||
if (this.state.couldResize) {
|
||||
this.startResizeAction(event);
|
||||
}
|
||||
}
|
||||
|
||||
startResizeAction(event: React.MouseEvent) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const {onResizeStart} = this.props;
|
||||
if (onResizeStart) {
|
||||
onResizeStart();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
resizing: true,
|
||||
resizingInitialCursor: {
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
},
|
||||
resizingInitialRect: this.getRect(),
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
_prevProps: InteractiveProps,
|
||||
prevState: InteractiveState,
|
||||
) {
|
||||
if (prevState.cursor !== this.state.cursor) {
|
||||
const {updateCursor} = this.props;
|
||||
if (updateCursor) {
|
||||
updateCursor(this.state.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetMoving() {
|
||||
const {onMoveEnd} = this.props;
|
||||
if (onMoveEnd) {
|
||||
onMoveEnd();
|
||||
}
|
||||
|
||||
if (this.context.os) {
|
||||
// resume os timers
|
||||
this.context.os.timers.resume();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
cursor: undefined,
|
||||
moving: false,
|
||||
movingInitialProps: undefined,
|
||||
resizingInitialCursor: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
resetResizing() {
|
||||
const {onResizeEnd} = this.props;
|
||||
if (onResizeEnd) {
|
||||
onResizeEnd();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
resizing: false,
|
||||
resizingInitialCursor: undefined,
|
||||
resizingInitialRect: undefined,
|
||||
resizingSides: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.endAction();
|
||||
}
|
||||
|
||||
endAction = () => {
|
||||
this.globalMouse = false;
|
||||
|
||||
window.removeEventListener('pointermove', this.onMouseMove);
|
||||
window.removeEventListener('pointerup', this.endAction);
|
||||
|
||||
if (this.state.moving) {
|
||||
this.resetMoving();
|
||||
}
|
||||
|
||||
if (this.state.resizing) {
|
||||
this.resetResizing();
|
||||
}
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
if (!this.state.resizing && !this.state.moving) {
|
||||
this.setState({
|
||||
cursor: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onClick = (e: React.MouseEvent) => {
|
||||
if (this.state.couldResize) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
calculateMove(event: MouseEvent) {
|
||||
const {movingInitialCursor, movingInitialProps} = this.state;
|
||||
|
||||
invariant(movingInitialProps, 'TODO');
|
||||
invariant(movingInitialCursor, 'TODO');
|
||||
|
||||
const {clientX: cursorLeft, clientY: cursorTop} = event;
|
||||
|
||||
const movedLeft = movingInitialCursor!.left - cursorLeft;
|
||||
const movedTop = movingInitialCursor!.top - cursorTop;
|
||||
|
||||
let newLeft = (movingInitialProps!.left || 0) - movedLeft;
|
||||
let newTop = (movingInitialProps!.top || 0) - movedTop;
|
||||
|
||||
if (event.altKey) {
|
||||
const snapProps = this.getRect();
|
||||
const windows = this.getPossibleTargetWindows(snapProps);
|
||||
newLeft = maybeSnapLeft(snapProps, windows, newLeft);
|
||||
newTop = maybeSnapTop(snapProps, windows, newTop);
|
||||
}
|
||||
|
||||
this.nextTop = newTop;
|
||||
this.nextLeft = newLeft;
|
||||
this.nextEvent = event;
|
||||
}
|
||||
|
||||
resize(width: number, height: number) {
|
||||
if (width === this.props.width && height === this.props.height) {
|
||||
// noop
|
||||
return;
|
||||
}
|
||||
|
||||
const {onResize} = this.props;
|
||||
if (!onResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
width = Math.max(this.props.minWidth || 0, width);
|
||||
height = Math.max(this.props.minHeight || 0, height);
|
||||
|
||||
const {maxHeight, maxWidth} = this.props;
|
||||
if (maxWidth != null) {
|
||||
width = Math.min(maxWidth, width);
|
||||
}
|
||||
if (maxHeight != null) {
|
||||
height = Math.min(maxHeight, height);
|
||||
}
|
||||
|
||||
onResize(width, height);
|
||||
}
|
||||
|
||||
move(top: number, left: number, event: MouseEvent) {
|
||||
top = Math.max(this.props.minTop || 0, top);
|
||||
left = Math.max(this.props.minLeft || 0, left);
|
||||
|
||||
if (top === this.props.top && left === this.props.left) {
|
||||
// noop
|
||||
return;
|
||||
}
|
||||
|
||||
const {onMove} = this.props;
|
||||
if (onMove) {
|
||||
onMove(top, left, event);
|
||||
}
|
||||
}
|
||||
|
||||
calculateResize(event: MouseEvent) {
|
||||
const {
|
||||
resizingInitialCursor,
|
||||
resizingInitialRect,
|
||||
resizingSides,
|
||||
} = this.state;
|
||||
|
||||
invariant(resizingInitialRect, 'TODO');
|
||||
invariant(resizingInitialCursor, 'TODO');
|
||||
invariant(resizingSides, 'TODO');
|
||||
|
||||
const deltaLeft = resizingInitialCursor!.left - event.clientX;
|
||||
const deltaTop = resizingInitialCursor!.top - event.clientY;
|
||||
|
||||
let newLeft = resizingInitialRect!.left;
|
||||
let newTop = resizingInitialRect!.top;
|
||||
|
||||
let newWidth = resizingInitialRect!.width;
|
||||
let newHeight = resizingInitialRect!.height;
|
||||
|
||||
// right
|
||||
if (resizingSides!.right === true) {
|
||||
newWidth -= deltaLeft;
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (resizingSides!.bottom === true) {
|
||||
newHeight -= deltaTop;
|
||||
}
|
||||
|
||||
const rect = this.getRect();
|
||||
|
||||
// left
|
||||
if (resizingSides!.left === true) {
|
||||
newLeft -= deltaLeft;
|
||||
newWidth += deltaLeft;
|
||||
|
||||
if (this.props.movable === true) {
|
||||
// prevent from being shrunk past the minimum width
|
||||
const right = rect.left + rect.width;
|
||||
const maxLeft = right - (this.props.minWidth || 0);
|
||||
|
||||
let cleanLeft = Math.max(0, newLeft);
|
||||
cleanLeft = Math.min(cleanLeft, maxLeft);
|
||||
newWidth -= Math.abs(newLeft - cleanLeft);
|
||||
newLeft = cleanLeft;
|
||||
}
|
||||
}
|
||||
|
||||
// top
|
||||
if (resizingSides!.top === true) {
|
||||
newTop -= deltaTop;
|
||||
newHeight += deltaTop;
|
||||
|
||||
if (this.props.movable === true) {
|
||||
// prevent from being shrunk past the minimum height
|
||||
const bottom = rect.top + rect.height;
|
||||
const maxTop = bottom - (this.props.minHeight || 0);
|
||||
|
||||
let cleanTop = Math.max(0, newTop);
|
||||
cleanTop = Math.min(cleanTop, maxTop);
|
||||
newHeight += newTop - cleanTop;
|
||||
newTop = cleanTop;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
const windows = this.getPossibleTargetWindows(rect);
|
||||
|
||||
if (resizingSides!.left === true) {
|
||||
const newLeft2 = maybeSnapLeft(rect, windows, newLeft);
|
||||
newWidth += newLeft - newLeft2;
|
||||
newLeft = newLeft2;
|
||||
}
|
||||
|
||||
if (resizingSides!.top === true) {
|
||||
const newTop2 = maybeSnapTop(rect, windows, newTop);
|
||||
newHeight += newTop - newTop2;
|
||||
newTop = newTop2;
|
||||
}
|
||||
|
||||
if (resizingSides!.bottom === true) {
|
||||
newHeight = maybeSnapTop(rect, windows, newTop + newHeight) - newTop;
|
||||
}
|
||||
|
||||
if (resizingSides!.right === true) {
|
||||
newWidth = maybeSnapLeft(rect, windows, newLeft + newWidth) - newLeft;
|
||||
}
|
||||
}
|
||||
|
||||
this.move(newTop, newLeft, event);
|
||||
this.resize(newWidth, newHeight);
|
||||
}
|
||||
|
||||
getRect(): Rect {
|
||||
const {props, ref} = this;
|
||||
invariant(ref, 'expected ref');
|
||||
|
||||
return {
|
||||
height: ref!.offsetHeight || 0,
|
||||
left: props.left || 0,
|
||||
top: props.top || 0,
|
||||
width: ref!.offsetWidth || 0,
|
||||
};
|
||||
}
|
||||
|
||||
getResizable(): ResizingSides {
|
||||
const {resizable} = this.props;
|
||||
|
||||
if (resizable === true) {
|
||||
return ALL_RESIZABLE;
|
||||
} else if (resizable == null || resizable === false) {
|
||||
return;
|
||||
} else {
|
||||
return resizable;
|
||||
}
|
||||
}
|
||||
|
||||
checkIfResizable(
|
||||
event: MouseEvent,
|
||||
):
|
||||
| {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
top: boolean;
|
||||
bottom: boolean;
|
||||
}
|
||||
| undefined {
|
||||
const canResize = this.getResizable();
|
||||
if (!canResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {left: offsetLeft, top: offsetTop} = this.ref.getBoundingClientRect();
|
||||
const {height, width} = this.getRect();
|
||||
|
||||
const x = event.clientX - offsetLeft;
|
||||
const y = event.clientY - offsetTop;
|
||||
|
||||
const atTop: boolean = y <= WINDOW_CURSOR_BOUNDARY;
|
||||
const atBottom: boolean = y >= height - WINDOW_CURSOR_BOUNDARY;
|
||||
|
||||
const atLeft: boolean = x <= WINDOW_CURSOR_BOUNDARY;
|
||||
const atRight: boolean = x >= width - WINDOW_CURSOR_BOUNDARY;
|
||||
|
||||
return {
|
||||
bottom: canResize.bottom === true && atBottom,
|
||||
left: canResize.left === true && atLeft,
|
||||
right: canResize.right === true && atRight,
|
||||
top: canResize.top === true && atTop,
|
||||
};
|
||||
}
|
||||
|
||||
calculateResizable(event: MouseEvent) {
|
||||
const resizing = this.checkIfResizable(event);
|
||||
if (!resizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canResize = this.getResizable();
|
||||
if (!canResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {bottom, left, right, top} = resizing;
|
||||
let newCursor;
|
||||
|
||||
const movingHorizontal = left || right;
|
||||
const movingVertical = top || left;
|
||||
|
||||
// left
|
||||
if (left) {
|
||||
newCursor = 'ew-resize';
|
||||
}
|
||||
|
||||
// right
|
||||
if (right) {
|
||||
newCursor = 'ew-resize';
|
||||
}
|
||||
|
||||
// if resizing vertically and one side can't be resized then use different cursor
|
||||
if (
|
||||
movingHorizontal &&
|
||||
(canResize.left !== true || canResize.right !== true)
|
||||
) {
|
||||
newCursor = 'col-resize';
|
||||
}
|
||||
|
||||
// top
|
||||
if (top) {
|
||||
newCursor = 'ns-resize';
|
||||
|
||||
// top left
|
||||
if (left) {
|
||||
newCursor = 'nwse-resize';
|
||||
}
|
||||
|
||||
// top right
|
||||
if (right) {
|
||||
newCursor = 'nesw-resize';
|
||||
}
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (bottom) {
|
||||
newCursor = 'ns-resize';
|
||||
|
||||
// bottom left
|
||||
if (left) {
|
||||
newCursor = 'nesw-resize';
|
||||
}
|
||||
|
||||
// bottom right
|
||||
if (right) {
|
||||
newCursor = 'nwse-resize';
|
||||
}
|
||||
}
|
||||
|
||||
// if resizing horziontally and one side can't be resized then use different cursor
|
||||
if (
|
||||
movingVertical &&
|
||||
!movingHorizontal &&
|
||||
(canResize.top !== true || canResize.bottom !== true)
|
||||
) {
|
||||
newCursor = 'row-resize';
|
||||
}
|
||||
|
||||
const resizingSides = {
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
};
|
||||
|
||||
const {onCanResize} = this.props;
|
||||
if (onCanResize) {
|
||||
onCanResize({});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
couldResize: Boolean(newCursor),
|
||||
cursor: newCursor,
|
||||
resizingSides,
|
||||
});
|
||||
}
|
||||
|
||||
setRef = (ref: HTMLElement | null) => {
|
||||
this.ref = ref;
|
||||
|
||||
const {innerRef} = this.props;
|
||||
if (innerRef) {
|
||||
innerRef(ref);
|
||||
}
|
||||
};
|
||||
|
||||
onLocalMouseMove = (event: React.MouseEvent) => {
|
||||
if (!this.globalMouse) {
|
||||
this.onMouseMove(event.nativeEvent);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {grow, height, left, movable, top, width, zIndex} = this.props;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
cursor: this.state.cursor,
|
||||
zIndex: zIndex == null ? 'auto' : zIndex,
|
||||
};
|
||||
|
||||
if (movable === true || top != null || left != null) {
|
||||
if (grow === true) {
|
||||
style.left = left || 0;
|
||||
style.top = top || 0;
|
||||
} else {
|
||||
style.transform = `translate3d(${left || 0}px, ${top || 0}px, 0)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (grow === true) {
|
||||
style.right = 0;
|
||||
style.bottom = 0;
|
||||
style.width = '100%';
|
||||
style.height = '100%';
|
||||
} else {
|
||||
style.width = width == null ? 'auto' : width;
|
||||
style.height = height == null ? 'auto' : height;
|
||||
}
|
||||
|
||||
if (this.props.style) {
|
||||
Object.assign(style, this.props.style);
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractiveContainer
|
||||
className={this.props.className}
|
||||
hidden={this.props.hidden}
|
||||
ref={this.setRef}
|
||||
onMouseDown={this.startAction}
|
||||
onMouseMove={this.onLocalMouseMove}
|
||||
onMouseLeave={this.onMouseLeave} // eslint-disable-next-line
|
||||
onClick={this.onClick}
|
||||
style={style}>
|
||||
{this.props.children}
|
||||
</InteractiveContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
18
desktop/app/src/ui/components/Label.tsx
Normal file
18
desktop/app/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/app/src/ui/components/Labeled.tsx
Normal file
28
desktop/app/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;
|
||||
41
desktop/app/src/ui/components/Link.tsx
Normal file
41
desktop/app/src/ui/components/Link.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
import {shell} from 'electron';
|
||||
import React from 'react';
|
||||
|
||||
const StyledLink = styled.span({
|
||||
color: colors.highlight,
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
});
|
||||
StyledLink.displayName = 'Link:StyledLink';
|
||||
|
||||
export default class Link extends Component<{
|
||||
href: string;
|
||||
children?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}> {
|
||||
onClick = () => {
|
||||
shell.openExternal(this.props.href);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<StyledLink onClick={this.onClick} style={this.props.style}>
|
||||
{this.props.children || this.props.href}
|
||||
</StyledLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
39
desktop/app/src/ui/components/LoadingIndicator.tsx
Normal file
39
desktop/app/src/ui/components/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import {keyframes} from 'emotion';
|
||||
|
||||
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;
|
||||
99
desktop/app/src/ui/components/Markdown.tsx
Normal file
99
desktop/app/src/ui/components/Markdown.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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, {PureComponent, CSSProperties} from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import {colors} from './colors';
|
||||
import {shell} from 'electron';
|
||||
|
||||
const Container = styled.div({
|
||||
padding: 10,
|
||||
});
|
||||
const Row = styled.div({
|
||||
marginTop: 5,
|
||||
marginBottom: 5,
|
||||
lineHeight: 1.34,
|
||||
});
|
||||
const Heading = styled.div<{level: number}>(props => ({
|
||||
fontSize: props.level === 1 ? 18 : 12,
|
||||
textTransform: props.level > 1 ? 'uppercase' : undefined,
|
||||
color: props.level > 1 ? '#90949c' : undefined,
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
fontWeight: props.level > 1 ? 'bold' : 'normal',
|
||||
}));
|
||||
const ListItem = styled.li({
|
||||
'list-style-type': 'circle',
|
||||
'list-style-position': 'inside',
|
||||
marginLeft: 10,
|
||||
});
|
||||
const Strong = styled.span({
|
||||
fontWeight: 'bold',
|
||||
color: '#1d2129',
|
||||
});
|
||||
const Emphasis = styled.span({
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
const Quote = styled(Row)({
|
||||
padding: 10,
|
||||
backgroundColor: '#f1f2f3',
|
||||
fontSize: 13,
|
||||
});
|
||||
const Code = styled.span({
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
backgroundColor: '#f1f2f3',
|
||||
});
|
||||
const Pre = styled(Row)({
|
||||
padding: 10,
|
||||
backgroundColor: '#f1f2f3',
|
||||
});
|
||||
class CodeBlock extends PureComponent<{value: string; language: string}> {
|
||||
render() {
|
||||
return (
|
||||
<Pre>
|
||||
<Code>{this.props.value}</Code>
|
||||
</Pre>
|
||||
);
|
||||
}
|
||||
}
|
||||
const Link = styled.span({
|
||||
color: colors.blue,
|
||||
});
|
||||
class LinkReference extends PureComponent<{href: string}> {
|
||||
render() {
|
||||
return (
|
||||
<Link onClick={() => shell.openExternal(this.props.href)}>
|
||||
{this.props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function Markdown(props: {source: string; style?: CSSProperties}) {
|
||||
return (
|
||||
<Container style={props.style}>
|
||||
<ReactMarkdown
|
||||
source={props.source}
|
||||
renderers={{
|
||||
heading: Heading,
|
||||
listItem: ListItem,
|
||||
paragraph: Row,
|
||||
strong: Strong,
|
||||
emphasis: Emphasis,
|
||||
inlineCode: Code,
|
||||
code: CodeBlock,
|
||||
blockquote: Quote,
|
||||
link: LinkReference,
|
||||
linkReference: LinkReference,
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
242
desktop/app/src/ui/components/MarkerTimeline.tsx
Normal file
242
desktop/app/src/ui/components/MarkerTimeline.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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 styled from '@emotion/styled';
|
||||
import Text from './Text';
|
||||
import FlexRow from './FlexRow';
|
||||
import {colors} from './colors';
|
||||
import React from 'react';
|
||||
|
||||
type DataPoint = {
|
||||
time: number;
|
||||
color?: string;
|
||||
label: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onClick?: (keys: Array<string>) => void;
|
||||
selected?: string | null | undefined;
|
||||
points: DataPoint[];
|
||||
lineHeight: number;
|
||||
maxGap: number;
|
||||
};
|
||||
|
||||
type MouseEventHandler = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => void;
|
||||
|
||||
const Markers = styled.div<{totalTime: number}>(props => ({
|
||||
position: 'relative',
|
||||
margin: 10,
|
||||
height: props.totalTime,
|
||||
'::before': {
|
||||
content: '""',
|
||||
width: 1,
|
||||
borderLeft: `1px dotted ${colors.light30}`,
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
bottom: 20,
|
||||
left: 5,
|
||||
},
|
||||
}));
|
||||
Markers.displayName = 'MarkerTimeline:Markers';
|
||||
|
||||
const Point = styled(FlexRow)<{
|
||||
positionY: number;
|
||||
onClick: MouseEventHandler | undefined;
|
||||
number: number | undefined;
|
||||
threadColor: string;
|
||||
selected: boolean;
|
||||
cut: boolean;
|
||||
}>(props => ({
|
||||
position: 'absolute',
|
||||
top: props.positionY,
|
||||
left: 0,
|
||||
right: 10,
|
||||
cursor: props.onClick ? 'pointer' : 'default',
|
||||
borderRadius: 3,
|
||||
alignItems: 'flex-start',
|
||||
lineHeight: '16px',
|
||||
':hover': {
|
||||
background: `linear-gradient(to top, rgba(255,255,255,0) 0, #ffffff 10px)`,
|
||||
paddingBottom: 5,
|
||||
zIndex: 2,
|
||||
'> span': {
|
||||
whiteSpace: 'initial',
|
||||
},
|
||||
},
|
||||
'::before': {
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
fontSize: 8,
|
||||
fontWeight: 500,
|
||||
content: props.number ? `'${props.number}'` : '""',
|
||||
display: 'inline-block',
|
||||
width: 9,
|
||||
height: 9,
|
||||
flexShrink: 0,
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
lineHeight: '9px',
|
||||
borderRadius: '999em',
|
||||
border: '1px solid rgba(0,0,0,0.2)',
|
||||
backgroundColor: props.threadColor,
|
||||
marginRight: 6,
|
||||
zIndex: 3,
|
||||
boxShadow: props.selected
|
||||
? `0 0 0 2px ${colors.macOSTitleBarIconSelected}`
|
||||
: undefined,
|
||||
},
|
||||
'::after': {
|
||||
content: props.cut ? '""' : undefined,
|
||||
position: 'absolute',
|
||||
width: 11,
|
||||
top: -20,
|
||||
left: 0,
|
||||
height: 2,
|
||||
background: colors.white,
|
||||
borderTop: `1px solid ${colors.light30}`,
|
||||
borderBottom: `1px solid ${colors.light30}`,
|
||||
transform: `skewY(-10deg)`,
|
||||
},
|
||||
}));
|
||||
Point.displayName = 'MakerTimeline:Point';
|
||||
|
||||
const Time = styled.span({
|
||||
color: colors.light30,
|
||||
fontWeight: 300,
|
||||
marginRight: 4,
|
||||
marginTop: -2,
|
||||
});
|
||||
Time.displayName = 'MakerTimeline:Time';
|
||||
|
||||
const Code = styled(Text)({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginTop: -1,
|
||||
});
|
||||
Code.displayName = 'MakerTimeline:Code';
|
||||
|
||||
type TimePoint = {
|
||||
timestamp: number;
|
||||
markerNames: Array<string>;
|
||||
markerKeys: Array<string>;
|
||||
isCut: boolean;
|
||||
positionY: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
timePoints: Array<TimePoint>;
|
||||
};
|
||||
|
||||
export default class MarkerTimeline extends Component<Props, State> {
|
||||
static defaultProps = {
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props: Props) {
|
||||
const sortedMarkers: [number, DataPoint[]][] = Array.from(
|
||||
props.points
|
||||
.reduce((acc: Map<number, DataPoint[]>, cv: DataPoint) => {
|
||||
const list = acc.get(cv.time);
|
||||
if (list) {
|
||||
list.push(cv);
|
||||
} else {
|
||||
acc.set(cv.time, [cv]);
|
||||
}
|
||||
return acc;
|
||||
}, new Map())
|
||||
.entries(),
|
||||
).sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const smallestGap = sortedMarkers.reduce((acc, cv, i, arr) => {
|
||||
if (i > 0) {
|
||||
return Math.min(acc, cv[0] - arr[i - 1][0]);
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, Infinity);
|
||||
|
||||
let positionY = 0;
|
||||
const timePoints: Array<TimePoint> = [];
|
||||
|
||||
for (let i = 0; i < sortedMarkers.length; i++) {
|
||||
const [timestamp, points] = sortedMarkers[i];
|
||||
let isCut = false;
|
||||
const color = sortedMarkers[i][1][0].color || colors.white;
|
||||
|
||||
if (i > 0) {
|
||||
const relativeTimestamp = timestamp - sortedMarkers[i - 1][0];
|
||||
const gap = (relativeTimestamp / smallestGap) * props.lineHeight;
|
||||
if (gap > props.maxGap) {
|
||||
positionY += props.maxGap;
|
||||
isCut = true;
|
||||
} else {
|
||||
positionY += gap;
|
||||
}
|
||||
}
|
||||
|
||||
timePoints.push({
|
||||
timestamp,
|
||||
markerNames: points.map(p => p.label),
|
||||
markerKeys: points.map(p => p.key),
|
||||
positionY,
|
||||
isCut,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
return {timePoints};
|
||||
}
|
||||
|
||||
state: State = {
|
||||
timePoints: [],
|
||||
};
|
||||
|
||||
render() {
|
||||
const {timePoints} = this.state;
|
||||
const {onClick} = this.props;
|
||||
|
||||
if (!this.props.points || this.props.points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Markers
|
||||
totalTime={
|
||||
timePoints[timePoints.length - 1].positionY + this.props.lineHeight
|
||||
}>
|
||||
{timePoints.map((p: TimePoint, i: number) => {
|
||||
return (
|
||||
<Point
|
||||
key={i}
|
||||
threadColor={p.color}
|
||||
cut={p.isCut}
|
||||
positionY={p.positionY}
|
||||
onClick={onClick ? () => onClick(p.markerKeys) : undefined}
|
||||
selected={
|
||||
this.props.selected
|
||||
? p.markerKeys.includes(this.props.selected)
|
||||
: false
|
||||
}
|
||||
number={
|
||||
p.markerNames.length > 1 ? p.markerNames.length : undefined
|
||||
}>
|
||||
<Time>{p.timestamp}ms</Time>{' '}
|
||||
<Code code>{p.markerNames.join(', ')}</Code>
|
||||
</Point>
|
||||
);
|
||||
})}
|
||||
</Markers>
|
||||
);
|
||||
}
|
||||
}
|
||||
53
desktop/app/src/ui/components/ModalOverlay.tsx
Normal file
53
desktop/app/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>
|
||||
);
|
||||
}
|
||||
}
|
||||
35
desktop/app/src/ui/components/MultiLineInput.tsx
Normal file
35
desktop/app/src/ui/components/MultiLineInput.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const multilineStyle = (props: {valid: boolean}) => ({
|
||||
border: `1px solid ${props.valid === false ? colors.red : colors.light15}`,
|
||||
borderRadius: 4,
|
||||
font: 'inherit',
|
||||
fontSize: '1em',
|
||||
height: '28px',
|
||||
lineHeight: '28px',
|
||||
marginRight: 5,
|
||||
|
||||
'&:disabled': {
|
||||
backgroundColor: '#ddd',
|
||||
borderColor: '#ccc',
|
||||
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/app/src/ui/components/Orderable.tsx
Normal file
430
desktop/app/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>
|
||||
);
|
||||
}
|
||||
}
|
||||
180
desktop/app/src/ui/components/Panel.tsx
Normal file
180
desktop/app/src/ui/components/Panel.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 React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import FlexColumn from './FlexColumn';
|
||||
import FlexBox from './FlexBox';
|
||||
import {colors} from './colors';
|
||||
import Glyph from './Glyph';
|
||||
|
||||
const BORDER = '1px solid #dddfe2';
|
||||
|
||||
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;
|
||||
},
|
||||
{
|
||||
collapsed: boolean;
|
||||
}
|
||||
> {
|
||||
static defaultProps: {
|
||||
floating: boolean;
|
||||
grow: boolean;
|
||||
collapsable: boolean;
|
||||
} = {
|
||||
grow: false,
|
||||
floating: true,
|
||||
collapsable: true,
|
||||
};
|
||||
|
||||
static PanelContainer = styled(FlexColumn)<{
|
||||
floating?: boolean;
|
||||
collapsed?: boolean;
|
||||
}>(props => ({
|
||||
flexShrink: 0,
|
||||
padding: props.floating ? 10 : 0,
|
||||
borderBottom: props.collapsed ? 'none' : BORDER,
|
||||
}));
|
||||
|
||||
static PanelHeader = styled(FlexBox)<{floating?: boolean; padded?: boolean}>(
|
||||
props => ({
|
||||
backgroundColor: '#f6f7f9',
|
||||
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: '#fff',
|
||||
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,
|
||||
} = this.props;
|
||||
const {collapsed} = this.state;
|
||||
return (
|
||||
<Panel.PanelContainer
|
||||
className={className}
|
||||
floating={floating}
|
||||
grow={grow}
|
||||
collapsed={collapsed}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
55
desktop/app/src/ui/components/PathBreadcrumbs.tsx
Normal file
55
desktop/app/src/ui/components/PathBreadcrumbs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 ButtonGroup from './ButtonGroup';
|
||||
import {Component} from 'react';
|
||||
import Button from './Button';
|
||||
import path from 'path';
|
||||
import React from 'react';
|
||||
|
||||
class PathBreadcrumbsItem extends Component<{
|
||||
name: string;
|
||||
path: string;
|
||||
isFolder: boolean;
|
||||
onClick: (path: string) => void;
|
||||
}> {
|
||||
onClick = () => {
|
||||
this.props.onClick(this.props.path);
|
||||
};
|
||||
|
||||
render() {
|
||||
return <Button onClick={this.onClick}>{this.props.name}</Button>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PathBreadcrumbs(props: {
|
||||
path: string;
|
||||
isFile?: boolean;
|
||||
onClick: (path: string) => void;
|
||||
}) {
|
||||
const parts = props.path === path.sep ? [''] : props.path.split(path.sep);
|
||||
const {onClick} = props;
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
{parts.map((part, i) => {
|
||||
const fullPath = parts.slice(0, i + 1).join(path.sep) || path.sep;
|
||||
return (
|
||||
<PathBreadcrumbsItem
|
||||
key={`${i}:${part}`}
|
||||
name={part || fullPath}
|
||||
path={fullPath}
|
||||
isFolder={!(props.isFile === true && i === parts.length - 1)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
110
desktop/app/src/ui/components/Popover.tsx
Normal file
110
desktop/app/src/ui/components/Popover.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 React, {PureComponent} from 'react';
|
||||
import FlexColumn from './FlexColumn';
|
||||
import styled from '@emotion/styled';
|
||||
import {colors} from './colors';
|
||||
|
||||
const Anchor = styled.img({
|
||||
zIndex: 6,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, calc(100% + 2px))',
|
||||
});
|
||||
Anchor.displayName = 'Popover.Anchor';
|
||||
|
||||
type Opts = {
|
||||
minWidth?: number;
|
||||
skewLeft?: boolean;
|
||||
};
|
||||
|
||||
const PopoverContainer = styled(FlexColumn)<{opts?: Opts}>(props => ({
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: 7,
|
||||
border: '1px solid rgba(0,0,0,0.3)',
|
||||
boxShadow: '0 2px 10px 0 rgba(0,0,0,0.3)',
|
||||
position: 'absolute',
|
||||
zIndex: 5,
|
||||
bottom: 0,
|
||||
marginTop: 15,
|
||||
left: '50%',
|
||||
minWidth: (props.opts && props.opts.minWidth) || 'auto',
|
||||
transform:
|
||||
props.opts && props.opts.skewLeft
|
||||
? 'translate(calc(-100% + 22px), calc(100% + 15px))'
|
||||
: 'translate(-50%, calc(100% + 15px))',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform:
|
||||
props.opts && props.opts.skewLeft
|
||||
? 'translateX(calc(-100% + 22px))'
|
||||
: 'translateX(-50%)',
|
||||
height: 13,
|
||||
top: -13,
|
||||
width: 26,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
}));
|
||||
PopoverContainer.displayName = 'Popover:PopoverContainer';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
onDismiss: Function;
|
||||
forceOpts?: Opts;
|
||||
};
|
||||
|
||||
export default class Popover extends PureComponent<Props> {
|
||||
_ref?: Element | null;
|
||||
|
||||
componentDidMount() {
|
||||
window.document.addEventListener('click', this.handleClick);
|
||||
window.document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.document.addEventListener('click', this.handleClick);
|
||||
window.document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
handleClick = (e: MouseEvent) => {
|
||||
if (this._ref && !this._ref.contains(e.target as Node)) {
|
||||
this.props.onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.props.onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
_setRef = (ref: Element | null) => {
|
||||
this._ref = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Anchor src="./anchor.svg" key="anchor" />
|
||||
<PopoverContainer
|
||||
ref={this._setRef}
|
||||
key="popup"
|
||||
opts={this.props.forceOpts || {}}>
|
||||
{this.props.children}
|
||||
</PopoverContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
47
desktop/app/src/ui/components/Radio.tsx
Normal file
47
desktop/app/src/ui/components/Radio.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
60
desktop/app/src/ui/components/ResizeSensor.tsx
Normal file
60
desktop/app/src/ui/components/ResizeSensor.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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 IFrame = styled.iframe({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
});
|
||||
IFrame.displayName = 'ResizeSensor:IFrame';
|
||||
|
||||
/**
|
||||
* Listener for resize events.
|
||||
*/
|
||||
export default class ResizeSensor extends Component<{
|
||||
/** Callback when resize happened */
|
||||
onResize: (e: UIEvent) => void;
|
||||
}> {
|
||||
iframe: HTMLIFrameElement | undefined | null;
|
||||
|
||||
setRef = (ref: HTMLIFrameElement | null) => {
|
||||
this.iframe = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <IFrame ref={this.setRef} />;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {iframe} = this;
|
||||
if (iframe && iframe.contentWindow != null) {
|
||||
iframe.contentWindow.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const {iframe} = this;
|
||||
if (iframe && iframe.contentWindow != null) {
|
||||
iframe.contentWindow.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
}
|
||||
|
||||
handleResize = (e: UIEvent) => {
|
||||
window.requestAnimationFrame(() => this.props.onResize(e));
|
||||
};
|
||||
}
|
||||
46
desktop/app/src/ui/components/RoundedSection.tsx
Normal file
46
desktop/app/src/ui/components/RoundedSection.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {colors} from './colors';
|
||||
import Heading from './Heading';
|
||||
import FlexColumn from './FlexColumn';
|
||||
|
||||
const Divider = styled.hr({
|
||||
margin: '16px -20px 20px -20px',
|
||||
border: 'none',
|
||||
borderTop: `1px solid ${colors.light05}`,
|
||||
});
|
||||
Divider.displayName = 'RoundedSection:Divider';
|
||||
|
||||
const Container = styled.div({
|
||||
background: colors.white,
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
|
||||
marginBottom: '20px',
|
||||
width: '100%',
|
||||
padding: 20,
|
||||
});
|
||||
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>
|
||||
<Heading>{title}</Heading>
|
||||
<Divider />
|
||||
<FlexColumn>{children}</FlexColumn>
|
||||
</Container>
|
||||
);
|
||||
|
||||
export default RoundedSection;
|
||||
99
desktop/app/src/ui/components/Select.tsx
Normal file
99
desktop/app/src/ui/components/Select.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
|
||||
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,
|
||||
}));
|
||||
SelectMenu.displayName = 'Select:SelectMenu';
|
||||
|
||||
/**
|
||||
* Dropdown to select from a list of options
|
||||
*/
|
||||
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;
|
||||
}> {
|
||||
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} = this.props;
|
||||
|
||||
let select = (
|
||||
<SelectMenu
|
||||
grow={grow}
|
||||
id={this.selectID}
|
||||
onChange={this.onChange}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
value={selected || ''}>
|
||||
{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;
|
||||
}
|
||||
}
|
||||
117
desktop/app/src/ui/components/Sheet.tsx
Normal file
117
desktop/app/src/ui/components/Sheet.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Component} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {connect} from 'react-redux';
|
||||
import {
|
||||
ACTIVE_SHEET_PLUGIN_SHEET,
|
||||
setActiveSheet,
|
||||
ActiveSheet,
|
||||
} from '../../reducers/application';
|
||||
import {State as Store} from '../../reducers';
|
||||
|
||||
export const PLUGIN_SHEET_ELEMENT_ID = 'pluginSheetContents';
|
||||
|
||||
type OwnProps = {
|
||||
/**
|
||||
* Function as child component (FaCC) to render the contents of the sheet.
|
||||
* A `onHide` function is passed as argument, that can be called to remove
|
||||
* the sheet.
|
||||
*/
|
||||
children: (onHide: () => void) => React.ReactNode | undefined;
|
||||
onHideSheet?: () => void;
|
||||
};
|
||||
|
||||
type StateFromProps = {
|
||||
/**
|
||||
* Function that is called when the sheet becomes hidden.
|
||||
*/
|
||||
activeSheet: ActiveSheet;
|
||||
};
|
||||
|
||||
type DispatchFromProps = {
|
||||
setActiveSheet: (sheet: ActiveSheet) => any;
|
||||
};
|
||||
|
||||
type State = {
|
||||
content: React.ReactNode | undefined;
|
||||
};
|
||||
|
||||
type Props = OwnProps & DispatchFromProps & StateFromProps;
|
||||
|
||||
/**
|
||||
* Usage: <Sheet>{onHide => <YourSheetContent onHide={onHide} />}</Sheet>
|
||||
*/
|
||||
class Sheet extends Component<Props, State> {
|
||||
static getDerivedStateFromProps(props: Props) {
|
||||
if (props.activeSheet === 'PLUGIN_SHEET') {
|
||||
return {
|
||||
content: props.children(() => {
|
||||
props.setActiveSheet(null);
|
||||
}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
state = {
|
||||
content: this.props.children(() => {
|
||||
this.props.setActiveSheet(null);
|
||||
}),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.showSheetIfContentsAvailable();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (prevState.content !== this.state.content) {
|
||||
this.showSheetIfContentsAvailable();
|
||||
}
|
||||
if (
|
||||
prevProps.activeSheet === ACTIVE_SHEET_PLUGIN_SHEET &&
|
||||
this.props.activeSheet !== ACTIVE_SHEET_PLUGIN_SHEET
|
||||
) {
|
||||
this.onHideSheet();
|
||||
}
|
||||
}
|
||||
|
||||
onHideSheet = () => {
|
||||
if (this.props.onHideSheet != null) {
|
||||
this.props.onHideSheet();
|
||||
}
|
||||
};
|
||||
|
||||
showSheetIfContentsAvailable = () => {
|
||||
if (this.state.content) {
|
||||
this.props.setActiveSheet('PLUGIN_SHEET');
|
||||
} else {
|
||||
this.props.setActiveSheet(null);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const container = document.getElementById(PLUGIN_SHEET_ELEMENT_ID);
|
||||
if (this.state.content && container) {
|
||||
return createPortal(this.state.content, container);
|
||||
}
|
||||
if (this.state.content) {
|
||||
console.warn(
|
||||
`The <Sheet> could not be displayed, because there was not element#${PLUGIN_SHEET_ELEMENT_ID}.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
|
||||
({application: {activeSheet}}) => ({activeSheet}),
|
||||
{setActiveSheet},
|
||||
)(Sheet);
|
||||
195
desktop/app/src/ui/components/Sidebar.tsx
Normal file
195
desktop/app/src/ui/components/Sidebar.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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 Interactive from './Interactive';
|
||||
import FlexColumn from './FlexColumn';
|
||||
import {colors} from './colors';
|
||||
import {Component} from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
BackgroundClipProperty,
|
||||
HeightProperty,
|
||||
WidthProperty,
|
||||
BackgroundColorProperty,
|
||||
} from 'csstype';
|
||||
import React from 'react';
|
||||
|
||||
const SidebarInteractiveContainer = styled(Interactive)({
|
||||
flex: 'none',
|
||||
});
|
||||
SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer';
|
||||
|
||||
type SidebarPosition = 'left' | 'top' | 'right' | 'bottom';
|
||||
|
||||
const SidebarContainer = styled(FlexColumn)<{
|
||||
position: 'right' | 'top' | 'left' | 'bottom';
|
||||
backgroundColor?: BackgroundClipProperty;
|
||||
overflow?: boolean;
|
||||
}>(props => ({
|
||||
backgroundColor: props.backgroundColor || colors.macOSTitleBarBackgroundBlur,
|
||||
borderLeft: props.position === 'right' ? '1px solid #b3b3b3' : 'none',
|
||||
borderTop: props.position === 'bottom' ? '1px solid #b3b3b3' : 'none',
|
||||
borderRight: props.position === 'left' ? '1px solid #b3b3b3' : 'none',
|
||||
borderBottom: props.position === 'top' ? '1px solid #b3b3b3' : '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?: BackgroundColorProperty;
|
||||
/**
|
||||
* 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?: WidthProperty<number>;
|
||||
height?: HeightProperty<number>;
|
||||
userChange: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A resizable sidebar.
|
||||
*/
|
||||
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';
|
||||
|
||||
if (horizontal) {
|
||||
width = width == null ? 200 : width;
|
||||
minWidth = minWidth == null ? 100 : minWidth;
|
||||
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 ? (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>
|
||||
);
|
||||
}
|
||||
}
|
||||
21
desktop/app/src/ui/components/SidebarLabel.tsx
Normal file
21
desktop/app/src/ui/components/SidebarLabel.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 {colors} from './colors';
|
||||
import Label from './Label';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const SidebarLabel = styled(Label)({
|
||||
color: colors.blackAlpha30,
|
||||
fontSize: 12,
|
||||
padding: 10,
|
||||
});
|
||||
SidebarLabel.displayName = 'SidebarLabel';
|
||||
|
||||
export default SidebarLabel;
|
||||
26
desktop/app/src/ui/components/SmallText.tsx
Normal file
26
desktop/app/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/app/src/ui/components/StackTrace.tsx
Normal file
202
desktop/app/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 {BackgroundColorProperty} from 'csstype';
|
||||
import {
|
||||
TableBodyRow,
|
||||
TableColumnSizes,
|
||||
TableColumns,
|
||||
TableBodyColumn,
|
||||
} from './table/types';
|
||||
|
||||
const Padder = styled.div<{
|
||||
padded?: boolean;
|
||||
backgroundColor?: BackgroundColorProperty;
|
||||
}>(({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={true}
|
||||
autoHeight
|
||||
zebra={false}
|
||||
columnOrder={columnOrder}
|
||||
columnSizes={columnSizes}
|
||||
highlightableRows={false}
|
||||
/>
|
||||
</Container>
|
||||
</Padder>
|
||||
);
|
||||
}
|
||||
}
|
||||
63
desktop/app/src/ui/components/StarButton.tsx
Normal file
63
desktop/app/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>
|
||||
);
|
||||
}
|
||||
36
desktop/app/src/ui/components/StatusIndicator.tsx
Normal file
36
desktop/app/src/ui/components/StatusIndicator.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 {colors} from './colors';
|
||||
|
||||
import {BackgroundColorProperty, HeightProperty} from 'csstype';
|
||||
|
||||
type Props = {
|
||||
statusColor: BackgroundColorProperty;
|
||||
diameter?: HeightProperty<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;
|
||||
45
desktop/app/src/ui/components/Tab.tsx
Normal file
45
desktop/app/src/ui/components/Tab.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 {WidthProperty} 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?: WidthProperty<number>;
|
||||
};
|
||||
|
||||
export default function Tab(_props: Props): JSX.Element {
|
||||
throw new Error("don't render me");
|
||||
}
|
||||
315
desktop/app/src/ui/components/Tabs.tsx
Normal file
315
desktop/app/src/ui/components/Tabs.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 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 {WidthProperty} from 'csstype';
|
||||
import React, {useContext} from 'react';
|
||||
import {TabsContext} from './TabsContainer';
|
||||
|
||||
const TabList = styled(FlexRow)({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
TabList.displayName = 'Tabs:TabList';
|
||||
|
||||
const TabListItem = styled.div<{
|
||||
active?: boolean;
|
||||
width?: WidthProperty<number>;
|
||||
container?: boolean;
|
||||
}>(props => ({
|
||||
background: props.container
|
||||
? props.active
|
||||
? 'linear-gradient(to bottom, #67a6f7 0%, #0072FA 100%)'
|
||||
: `linear-gradient(to bottom, white 0%,${colors.macOSTitleBarButtonBackgroundBlur} 100%)`
|
||||
: props.active
|
||||
? colors.light15
|
||||
: colors.light02,
|
||||
borderBottom: props.container ? '1px solid #B8B8B8' : '1px solid #dddfe2',
|
||||
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,
|
||||
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: props.active ? colors.light15 : colors.light05,
|
||||
},
|
||||
}));
|
||||
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: '#000',
|
||||
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%',
|
||||
});
|
||||
TabContent.displayName = 'Tabs:TabContent';
|
||||
|
||||
/**
|
||||
* A Tabs component.
|
||||
*/
|
||||
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;
|
||||
}) {
|
||||
const tabsContainer =
|
||||
props.classic === true ? false : useContext(TabsContext);
|
||||
|
||||
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
|
||||
? (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target !== closeButton) {
|
||||
onActive(key);
|
||||
}
|
||||
}
|
||||
: 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={true}>
|
||||
<TabList>
|
||||
{before}
|
||||
{tabList}
|
||||
{after}
|
||||
</TabList>
|
||||
{tabContents}
|
||||
{tabSiblings}
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
32
desktop/app/src/ui/components/TabsContainer.tsx
Normal file
32
desktop/app/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={true}>{props.children}</TabsContext.Provider>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
62
desktop/app/src/ui/components/Text.tsx
Normal file
62
desktop/app/src/ui/components/Text.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 {
|
||||
ColorProperty,
|
||||
FontSizeProperty,
|
||||
TextAlignProperty,
|
||||
FontFamilyProperty,
|
||||
WhiteSpaceProperty,
|
||||
WordWrapProperty,
|
||||
CursorProperty,
|
||||
} from 'csstype';
|
||||
|
||||
/**
|
||||
* A Text component.
|
||||
*/
|
||||
const Text = styled.span<{
|
||||
color?: ColorProperty;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
align?: TextAlignProperty;
|
||||
size?: FontSizeProperty<number>;
|
||||
code?: boolean;
|
||||
family?: FontFamilyProperty;
|
||||
selectable?: boolean;
|
||||
wordWrap?: WordWrapProperty;
|
||||
whiteSpace?: WhiteSpaceProperty;
|
||||
cursor?: CursorProperty;
|
||||
}>(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 || (props.code && typeof props.selectable === 'undefined')
|
||||
? 'text'
|
||||
: 'none',
|
||||
wordWrap: props.code ? 'break-word' : props.wordWrap,
|
||||
whiteSpace:
|
||||
props.code && typeof props.whiteSpace === 'undefined'
|
||||
? 'pre'
|
||||
: props.whiteSpace,
|
||||
}));
|
||||
Text.displayName = 'Text';
|
||||
|
||||
export default Text;
|
||||
24
desktop/app/src/ui/components/TextParagraph.tsx
Normal file
24
desktop/app/src/ui/components/TextParagraph.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 styled from '@emotion/styled';
|
||||
|
||||
/**
|
||||
* A TextParagraph component.
|
||||
*/
|
||||
const TextParagraph = styled.div({
|
||||
marginBottom: 10,
|
||||
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
});
|
||||
TextParagraph.displayName = 'TextParagraph';
|
||||
|
||||
export default TextParagraph;
|
||||
29
desktop/app/src/ui/components/Textarea.tsx
Normal file
29
desktop/app/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;
|
||||
108
desktop/app/src/ui/components/ToggleSwitch.tsx
Normal file
108
desktop/app/src/ui/components/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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.grey,
|
||||
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,
|
||||
});
|
||||
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 'flipper';
|
||||
* <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>
|
||||
);
|
||||
}
|
||||
45
desktop/app/src/ui/components/Toolbar.tsx
Normal file
45
desktop/app/src/ui/components/Toolbar.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 {colors} from './colors';
|
||||
import FlexRow from './FlexRow';
|
||||
import FlexBox from './FlexBox';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
/**
|
||||
* A toolbar.
|
||||
*/
|
||||
const Toolbar = styled(FlexRow)<{
|
||||
position?: 'bottom' | 'top';
|
||||
compact?: boolean;
|
||||
}>(props => ({
|
||||
backgroundColor: colors.light02,
|
||||
borderBottom:
|
||||
props.position === 'bottom'
|
||||
? 'none'
|
||||
: `1px solid ${colors.sectionHeaderBorder}`,
|
||||
borderTop:
|
||||
props.position === 'bottom'
|
||||
? `1px solid ${colors.sectionHeaderBorder}`
|
||||
: 'none',
|
||||
flexShrink: 0,
|
||||
height: props.compact ? 28 : 42,
|
||||
lineHeight: '32px',
|
||||
alignItems: 'center',
|
||||
padding: 6,
|
||||
width: '100%',
|
||||
}));
|
||||
Toolbar.displayName = 'Toolbar';
|
||||
|
||||
export const Spacer = styled(FlexBox)({
|
||||
flexGrow: 1,
|
||||
});
|
||||
Spacer.displayName = 'Spacer';
|
||||
|
||||
export default Toolbar;
|
||||
63
desktop/app/src/ui/components/Tooltip.tsx
Normal file
63
desktop/app/src/ui/components/Tooltip.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 {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;
|
||||
};
|
||||
|
||||
export default function Tooltip(props: TooltipProps) {
|
||||
const tooltipManager = useContext(TooltipContext);
|
||||
const ref = useRef<HTMLDivElement | null>();
|
||||
const isOpen = useRef<boolean>(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (isOpen.current) {
|
||||
tooltipManager.close();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onMouseEnter = useCallback(() => {
|
||||
if (ref.current && props.title) {
|
||||
tooltipManager.open(ref.current, props.title, props.options || {});
|
||||
isOpen.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
if (isOpen.current) {
|
||||
tooltipManager.close();
|
||||
isOpen.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TooltipContainer
|
||||
ref={ref as any}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}>
|
||||
{props.children}
|
||||
</TooltipContainer>
|
||||
);
|
||||
}
|
||||
300
desktop/app/src/ui/components/TooltipProvider.tsx
Normal file
300
desktop/app/src/ui/components/TooltipProvider.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 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 {
|
||||
TopProperty,
|
||||
LeftProperty,
|
||||
BottomProperty,
|
||||
RightProperty,
|
||||
BackgroundColorProperty,
|
||||
LineHeightProperty,
|
||||
PaddingProperty,
|
||||
BorderRadiusProperty,
|
||||
MaxWidthProperty,
|
||||
ColorProperty,
|
||||
WidthProperty,
|
||||
} from 'csstype';
|
||||
import React from 'react';
|
||||
|
||||
const defaultOptions = {
|
||||
backgroundColor: colors.blueGrey,
|
||||
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: TopProperty<number>;
|
||||
left: LeftProperty<number>;
|
||||
bottom: BottomProperty<number>;
|
||||
right: RightProperty<number>;
|
||||
options: {
|
||||
backgroundColor: BackgroundColorProperty;
|
||||
lineHeight: LineHeightProperty<number>;
|
||||
padding: PaddingProperty<number>;
|
||||
borderRadius: BorderRadiusProperty<number>;
|
||||
width: WidthProperty<number>;
|
||||
maxWidth: MaxWidthProperty<number>;
|
||||
color: ColorProperty;
|
||||
};
|
||||
}>(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 = 15;
|
||||
// vertical offset on tail when position is 'toLeft' or 'toRight'
|
||||
const TAIL_LR_POSITION_HORIZONTAL_OFFSET = 5;
|
||||
|
||||
const TooltipTail = styled.div<{
|
||||
top: TopProperty<number>;
|
||||
left: LeftProperty<number>;
|
||||
bottom: BottomProperty<number>;
|
||||
right: RightProperty<number>;
|
||||
options: {
|
||||
backgroundColor: BackgroundColorProperty;
|
||||
};
|
||||
}>(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 TooltipProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type TooltipObject = {
|
||||
rect: ClientRect;
|
||||
title: React.ReactNode;
|
||||
options: TooltipOptions;
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
tooltip: TooltipObject | null | undefined;
|
||||
timeoutID: ReturnType<typeof setTimeout> | null | undefined;
|
||||
};
|
||||
|
||||
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: LeftProperty<number> = 'auto';
|
||||
let top: TopProperty<number> = 'auto';
|
||||
let bottom: BottomProperty<number> = 'auto';
|
||||
let right: RightProperty<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;
|
||||
} else if (opts.position === 'toLeft') {
|
||||
right =
|
||||
window.innerWidth -
|
||||
tooltip.rect.left +
|
||||
TAIL_LR_POSITION_HORIZONTAL_OFFSET;
|
||||
top = tooltip.rect.top;
|
||||
}
|
||||
|
||||
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: LeftProperty<number> = 'auto';
|
||||
let top: TopProperty<number> = 'auto';
|
||||
let bottom: BottomProperty<number> = 'auto';
|
||||
let right: RightProperty<number> = 'auto';
|
||||
|
||||
if (opts.position === 'below') {
|
||||
left = tooltip.rect.left;
|
||||
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;
|
||||
} 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 + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
|
||||
} 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 + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipBubble
|
||||
key="bubble"
|
||||
top={top}
|
||||
left={left}
|
||||
bottom={bottom}
|
||||
right={right}
|
||||
options={opts}>
|
||||
{tooltip.title}
|
||||
</TooltipBubble>
|
||||
);
|
||||
}
|
||||
21
desktop/app/src/ui/components/VBox.tsx
Normal file
21
desktop/app/src/ui/components/VBox.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 styled from '@emotion/styled';
|
||||
import FlexColumn from './FlexColumn';
|
||||
|
||||
/**
|
||||
* Container that applies a standardized bottom margin for vertical spacing
|
||||
*/
|
||||
const VBox = styled(FlexColumn)({
|
||||
marginBottom: 10,
|
||||
});
|
||||
VBox.displayName = 'VBox';
|
||||
|
||||
export default VBox;
|
||||
19
desktop/app/src/ui/components/VerticalRule.tsx
Normal file
19
desktop/app/src/ui/components/VerticalRule.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 VerticalRule = styled.div({
|
||||
backgroundColor: '#c9ced4',
|
||||
width: 3,
|
||||
margin: '0',
|
||||
});
|
||||
VerticalRule.displayName = 'VerticalRule';
|
||||
|
||||
export default VerticalRule;
|
||||
27
desktop/app/src/ui/components/View.tsx
Normal file
27
desktop/app/src/ui/components/View.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 styled from '@emotion/styled';
|
||||
|
||||
type Props = {
|
||||
grow?: boolean;
|
||||
scrollable?: boolean;
|
||||
maxHeight?: number;
|
||||
};
|
||||
|
||||
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;
|
||||
45
desktop/app/src/ui/components/ViewWithSize.tsx
Normal file
45
desktop/app/src/ui/components/ViewWithSize.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 {Component} from 'react';
|
||||
|
||||
type ViewWithSizeProps = {
|
||||
onSize: (width: number, height: number) => any;
|
||||
};
|
||||
|
||||
type ViewWithSizeState = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export default class ViewWithSize extends Component<
|
||||
ViewWithSizeProps,
|
||||
ViewWithSizeState
|
||||
> {
|
||||
constructor(props: ViewWithSizeProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {height: window.innerHeight, width: window.innerWidth};
|
||||
}
|
||||
|
||||
_onResize = () => {
|
||||
this.setState({height: window.innerHeight, width: window.innerWidth});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.onSize(this.state.width, this.state.height);
|
||||
}
|
||||
}
|
||||
145
desktop/app/src/ui/components/VirtualList.tsx
Normal file
145
desktop/app/src/ui/components/VirtualList.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
import View from './View';
|
||||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
import {HeightProperty, TopProperty} from 'csstype';
|
||||
|
||||
const Inner = styled(FlexColumn)<{height: HeightProperty<number>}>(
|
||||
({height}) => ({
|
||||
alignItems: 'flex-start',
|
||||
height,
|
||||
minHeight: '100%',
|
||||
minWidth: '100%',
|
||||
overflow: 'visible',
|
||||
width: '100%',
|
||||
}),
|
||||
);
|
||||
Inner.displayName = 'VirtualList:Inner';
|
||||
|
||||
const Content = styled(FlexColumn)<{top: TopProperty<number>}>(({top}) => ({
|
||||
alignItems: 'flex-start',
|
||||
height: '100%',
|
||||
marginTop: top,
|
||||
minWidth: '100%',
|
||||
overflow: 'visible',
|
||||
}));
|
||||
Content.displayName = 'VirtualList:Content';
|
||||
|
||||
type VirtualListProps = {
|
||||
data: Array<any>;
|
||||
renderRow: (data: any, i: number) => any;
|
||||
rowHeight: number;
|
||||
overscanCount: number;
|
||||
sync?: boolean;
|
||||
wrapInner?: (data: any) => any;
|
||||
};
|
||||
|
||||
type VirtualListState = {
|
||||
offset: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export default class VirtualList extends Component<
|
||||
VirtualListProps,
|
||||
VirtualListState
|
||||
> {
|
||||
constructor(props: VirtualListProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
height: 0,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
overscanCount: 10,
|
||||
};
|
||||
|
||||
ref: HTMLElement | undefined | null;
|
||||
|
||||
setRef = (ref: HTMLElement | null) => {
|
||||
this.ref = ref;
|
||||
};
|
||||
|
||||
resize = () => {
|
||||
if (this.ref && this.state.height !== this.ref.offsetHeight) {
|
||||
this.setState({height: this.ref.offsetHeight});
|
||||
}
|
||||
};
|
||||
|
||||
handleScroll = () => {
|
||||
this.setState({offset: this.ref ? this.ref.scrollTop : 0});
|
||||
if (this.props.sync === true) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
componentDidUpdate() {
|
||||
this.resize();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.resize();
|
||||
window.addEventListener('resize', this.resize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {data, overscanCount, renderRow, rowHeight, wrapInner} = this.props;
|
||||
const {height, offset} = this.state;
|
||||
|
||||
// first visible row index
|
||||
// eslint-disable-next-line no-bitwise
|
||||
let start = (offset / rowHeight) | 0;
|
||||
|
||||
// actual number of visible rows (without overscan)
|
||||
// eslint-disable-next-line no-bitwise
|
||||
let visibleRowCount = (height / rowHeight) | 0;
|
||||
|
||||
// Overscan: render blocks of rows modulo an overscan row count
|
||||
// This dramatically reduces DOM writes during scrolling
|
||||
if (overscanCount) {
|
||||
start = Math.max(0, start - (start % overscanCount));
|
||||
visibleRowCount += overscanCount;
|
||||
}
|
||||
|
||||
// last visible + overscan row index
|
||||
const end = start + 1 + visibleRowCount;
|
||||
|
||||
// data slice currently in viewport plus overscan items
|
||||
const selection = data.slice(start, end);
|
||||
|
||||
let inner = (
|
||||
<Inner height={data.length * rowHeight}>
|
||||
<Content top={start * rowHeight}>{selection.map(renderRow)}</Content>
|
||||
</Inner>
|
||||
);
|
||||
|
||||
if (wrapInner) {
|
||||
inner = wrapInner(inner);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
grow={true}
|
||||
onScroll={this.handleScroll}
|
||||
ref={this.setRef}
|
||||
scrollable={true}>
|
||||
{inner}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 MarkerTimeline from '../MarkerTimeline';
|
||||
|
||||
test('merges points with same timestamp', () => {
|
||||
const points = [
|
||||
{key: 'marker1', label: 'marker1', time: 41},
|
||||
{key: 'marker2', label: 'marker2', time: 41},
|
||||
];
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points,
|
||||
});
|
||||
expect(timePoints[0].markerNames).toContain('marker1');
|
||||
expect(timePoints[0].markerNames).toContain('marker2');
|
||||
});
|
||||
|
||||
test('sorts points', () => {
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points: [
|
||||
{key: 'marker1', label: 'marker1', time: 20},
|
||||
{key: 'marker2', label: 'marker2', time: -50},
|
||||
],
|
||||
});
|
||||
expect(timePoints[0].timestamp).toBe(-50);
|
||||
expect(timePoints[1].timestamp).toBe(20);
|
||||
});
|
||||
|
||||
test('handles negative timestamps', () => {
|
||||
const points = [{label: 'preStartPoint', key: 'preStartPoint', time: -50}];
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points,
|
||||
});
|
||||
expect(timePoints[0].timestamp).toBe(-50);
|
||||
});
|
||||
|
||||
test('no points', () => {
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points: [],
|
||||
});
|
||||
expect(timePoints).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('handles single point', () => {
|
||||
const points = [{key: '1', label: 'single point', time: 0}];
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: 100,
|
||||
points,
|
||||
});
|
||||
expect(timePoints).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('cuts long gaps', () => {
|
||||
const points = [
|
||||
{key: '1', label: 'single point', time: 1},
|
||||
{key: '2', label: 'single point', time: 1000},
|
||||
{key: '3', label: 'single point', time: 1001},
|
||||
];
|
||||
|
||||
const MAX_GAP = 100;
|
||||
|
||||
const {timePoints} = MarkerTimeline.getDerivedStateFromProps({
|
||||
lineHeight: 22,
|
||||
maxGap: MAX_GAP,
|
||||
points,
|
||||
});
|
||||
|
||||
expect(timePoints[0].isCut).toBe(false);
|
||||
expect(timePoints[1].isCut).toBe(true);
|
||||
expect(timePoints[1].positionY).toBe(timePoints[0].positionY + MAX_GAP);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`handles single point 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"color": "#ffffff",
|
||||
"isCut": false,
|
||||
"markerKeys": Array [
|
||||
"1",
|
||||
],
|
||||
"markerNames": Array [
|
||||
"single point",
|
||||
],
|
||||
"positionY": 0,
|
||||
"timestamp": 0,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`no points 1`] = `Array []`;
|
||||
281
desktop/app/src/ui/components/colors.tsx
Normal file
281
desktop/app/src/ui/components/colors.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
|
||||
*/
|
||||
|
||||
// Last updated: Jan 30 2016
|
||||
|
||||
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
|
||||
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
|
||||
grey: '#88A2AB', // Grey - Debug
|
||||
greyTint: '#E7ECEE',
|
||||
greyTint2: '#e5e5e5', // Grey - Can be used in demarcation with greyStackTraceTint
|
||||
greyTint3: '#515151', // Grey - Can be used as the color for the title
|
||||
greyStackTraceTint: '#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
|
||||
blueGrey: '#5f6673', // Blue Grey
|
||||
blueGreyDark3: '#23272f', // Blue Grey - Dark 3
|
||||
blueGreyDark2: '#303846', // Blue Grey - Dark 2
|
||||
blueGreyDark1: '#4f5766', // Blue Grey - Dark 1
|
||||
blueGreyTint15: '#777d88', // Blue Grey - Tint 15
|
||||
blueGreyTint30: '#8f949d', // Blue Grey - Tint 30
|
||||
blueGreyTint50: '#afb3b9', // Blue Grey - Tint 50
|
||||
blueGreyTint70: '#cfd1d5', // Blue Grey - Tint 70
|
||||
blueGreyTint90: '#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',
|
||||
Messenger: '#0088FA',
|
||||
Instagram: '#E61E68',
|
||||
Flipper: '#8155cb',
|
||||
};
|
||||
211
desktop/app/src/ui/components/console.tsx
Normal file
211
desktop/app/src/ui/components/console.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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, {Component} from 'react';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import {colors} from './colors';
|
||||
import ManagedTable from './table/ManagedTable';
|
||||
import FlexColumn from './FlexColumn';
|
||||
import Text from './Text';
|
||||
import ManagedDataInspector from './data-inspector/ManagedDataInspector';
|
||||
import Input from './Input';
|
||||
import View from './View';
|
||||
import styled from '@emotion/styled';
|
||||
import {TableBodyRow, TableRows} from './table/types';
|
||||
import {PluginClient} from '../../plugin';
|
||||
|
||||
type ValueWithType = {
|
||||
type: string;
|
||||
value: any;
|
||||
};
|
||||
type SuccessResult = {
|
||||
isSuccess: true;
|
||||
value: ValueWithType;
|
||||
};
|
||||
type FailedResult = {
|
||||
isSuccess: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type CommandResult = SuccessResult | FailedResult;
|
||||
|
||||
type Props = {
|
||||
client: PluginClient;
|
||||
getContext: () => string;
|
||||
};
|
||||
type State = {
|
||||
isConsoleEnabled: boolean;
|
||||
script: string;
|
||||
previousExecutions: Array<{
|
||||
command: string;
|
||||
result: CommandResult;
|
||||
}>;
|
||||
};
|
||||
|
||||
class ConsoleError extends Component<{
|
||||
error: Error | string | void;
|
||||
className?: string;
|
||||
}> {
|
||||
static Container = styled(CodeBlock)({
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
overflow: 'auto',
|
||||
flexGrow: 1,
|
||||
margin: '0 -8px',
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
render() {
|
||||
const {className, error} = this.props;
|
||||
return (
|
||||
<ConsoleError.Container className={className}>
|
||||
{(error || '').toString()}
|
||||
</ConsoleError.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Console extends Component<Props, State> {
|
||||
static title = 'Console';
|
||||
static id = 'Console';
|
||||
static icon = 'chevron-right';
|
||||
|
||||
static TableColumns = {
|
||||
command: {
|
||||
value: 'Commands',
|
||||
},
|
||||
};
|
||||
|
||||
static Window = styled(FlexColumn)({
|
||||
padding: '15px',
|
||||
flexGrow: 1,
|
||||
});
|
||||
static Input = styled(Input)({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isConsoleEnabled: false,
|
||||
script: '',
|
||||
previousExecutions: [],
|
||||
};
|
||||
}
|
||||
|
||||
executeScriptOnDevice = () => {
|
||||
this.props.client
|
||||
.call('executeCommand', {
|
||||
command: this.state.script,
|
||||
context: this.props.getContext(),
|
||||
})
|
||||
.then((result: ValueWithType) => {
|
||||
this.setState({
|
||||
script: '',
|
||||
previousExecutions: [
|
||||
...this.state.previousExecutions,
|
||||
{
|
||||
command: this.state.script,
|
||||
result: {isSuccess: true, value: result},
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
.catch((onReject?) => {
|
||||
this.setState({
|
||||
previousExecutions: [
|
||||
...this.state.previousExecutions,
|
||||
{
|
||||
command: this.state.script,
|
||||
result: {
|
||||
isSuccess: false,
|
||||
error: (onReject && onReject.message) || '',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({script: event.target.value});
|
||||
};
|
||||
|
||||
onSubmit = (event: React.FormEvent) => {
|
||||
if (this.state.script != '') {
|
||||
this.executeScriptOnDevice();
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
buildCommandResultRowPair(
|
||||
command: string,
|
||||
result: CommandResult,
|
||||
index: number,
|
||||
): TableRows {
|
||||
const key = index * 2;
|
||||
const commandRow: TableBodyRow = {
|
||||
columns: {
|
||||
command: {value: <Text>{command}</Text>},
|
||||
},
|
||||
key: key.toString(),
|
||||
};
|
||||
const resultRow: TableBodyRow = {
|
||||
columns: {
|
||||
command: {
|
||||
value: result.isSuccess ? (
|
||||
<ManagedDataInspector
|
||||
data={result.value}
|
||||
expandRoot={true}
|
||||
collapsed={true}
|
||||
/>
|
||||
) : (
|
||||
<ConsoleError error={(result as FailedResult).error} />
|
||||
),
|
||||
},
|
||||
},
|
||||
key: (key + 1).toString(),
|
||||
};
|
||||
return [commandRow, resultRow];
|
||||
}
|
||||
|
||||
renderPreviousCommands() {
|
||||
const rows: TableRows = this.state.previousExecutions
|
||||
.map(({command, result}, index) =>
|
||||
this.buildCommandResultRowPair(command, result, index),
|
||||
)
|
||||
.reduce((x, y) => x.concat(y), []);
|
||||
return rows.length ? (
|
||||
<ManagedTable
|
||||
columns={Console.TableColumns}
|
||||
rows={rows}
|
||||
multiline={true}
|
||||
stickyBottom={true}
|
||||
highlightableRows={false}
|
||||
hideHeader={true}
|
||||
autoHeight={true}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Console.Window>
|
||||
<View grow={true}>{this.renderPreviousCommands()}</View>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<Console.Input
|
||||
onChange={this.onInputChange}
|
||||
placeholder="Command"
|
||||
value={this.state.script}
|
||||
/>
|
||||
</form>
|
||||
</Console.Window>
|
||||
);
|
||||
}
|
||||
}
|
||||
665
desktop/app/src/ui/components/data-inspector/DataDescription.tsx
Normal file
665
desktop/app/src/ui/components/data-inspector/DataDescription.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* 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 Link from '../Link';
|
||||
import {DataInspectorSetValue} from './DataInspector';
|
||||
import {PureComponent} from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {SketchPicker, CompactPicker} from 'react-color';
|
||||
import {Component, Fragment} from 'react';
|
||||
import Popover from '../Popover';
|
||||
import {colors} from '../colors';
|
||||
import Input from '../Input';
|
||||
import React, {KeyboardEvent} from 'react';
|
||||
import Glyph from '../Glyph';
|
||||
|
||||
const NullValue = styled.span({
|
||||
color: 'rgb(128, 128, 128)',
|
||||
});
|
||||
NullValue.displayName = 'DataDescription:NullValue';
|
||||
|
||||
const UndefinedValue = styled.span({
|
||||
color: 'rgb(128, 128, 128)',
|
||||
});
|
||||
UndefinedValue.displayName = 'DataDescription:UndefinedValue';
|
||||
|
||||
const StringValue = styled.span({
|
||||
color: colors.cherryDark1,
|
||||
wordWrap: 'break-word',
|
||||
});
|
||||
StringValue.displayName = 'DataDescription:StringValue';
|
||||
|
||||
const ColorValue = styled.span({
|
||||
color: colors.blueGrey,
|
||||
});
|
||||
ColorValue.displayName = 'DataDescription:ColorValue';
|
||||
|
||||
const SymbolValue = styled.span({
|
||||
color: 'rgb(196, 26, 22)',
|
||||
});
|
||||
SymbolValue.displayName = 'DataDescription:SymbolValue';
|
||||
|
||||
const NumberValue = styled.span({
|
||||
color: colors.tealDark1,
|
||||
});
|
||||
NumberValue.displayName = 'DataDescription:NumberValue';
|
||||
|
||||
const ColorBox = styled.span<{color: string}>(props => ({
|
||||
backgroundColor: props.color,
|
||||
boxShadow: 'inset 0 0 1px rgba(0, 0, 0, 1)',
|
||||
display: 'inline-block',
|
||||
height: 12,
|
||||
marginRight: 5,
|
||||
verticalAlign: 'middle',
|
||||
width: 12,
|
||||
}));
|
||||
ColorBox.displayName = 'DataDescription:ColorBox';
|
||||
|
||||
const FunctionKeyword = styled.span({
|
||||
color: 'rgb(170, 13, 145)',
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
FunctionKeyword.displayName = 'DataDescription:FunctionKeyword';
|
||||
|
||||
const FunctionName = styled.span({
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
FunctionName.displayName = 'DataDescription:FunctionName';
|
||||
|
||||
const ColorPickerDescription = styled.div({
|
||||
display: 'inline',
|
||||
position: 'relative',
|
||||
});
|
||||
ColorPickerDescription.displayName = 'DataDescription:ColorPickerDescription';
|
||||
|
||||
type DataDescriptionProps = {
|
||||
path?: Array<string>;
|
||||
type: string;
|
||||
value: any;
|
||||
extra?: any;
|
||||
setValue: DataInspectorSetValue | null | undefined;
|
||||
};
|
||||
|
||||
type DescriptionCommitOptions = {
|
||||
value: any;
|
||||
keep: boolean;
|
||||
clear: boolean;
|
||||
set: boolean;
|
||||
};
|
||||
|
||||
class NumberTextEditor extends PureComponent<{
|
||||
commit: (opts: DescriptionCommitOptions) => void;
|
||||
type: string;
|
||||
value: any;
|
||||
origValue: any;
|
||||
}> {
|
||||
onNumberTextInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val =
|
||||
this.props.type === 'number'
|
||||
? parseFloat(e.target.value)
|
||||
: e.target.value;
|
||||
this.props.commit({
|
||||
clear: false,
|
||||
keep: true,
|
||||
value: val,
|
||||
set: false,
|
||||
});
|
||||
};
|
||||
|
||||
onNumberTextInputKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val =
|
||||
this.props.type === 'number'
|
||||
? parseFloat(this.props.value)
|
||||
: this.props.value;
|
||||
this.props.commit({clear: true, keep: true, value: val, set: true});
|
||||
} else if (e.key === 'Escape') {
|
||||
this.props.commit({
|
||||
clear: true,
|
||||
keep: false,
|
||||
value: this.props.origValue,
|
||||
set: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onNumberTextRef = (ref: HTMLElement | undefined | null) => {
|
||||
if (ref) {
|
||||
ref.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onNumberTextBlur = () => {
|
||||
this.props.commit({
|
||||
clear: true,
|
||||
keep: true,
|
||||
value: this.props.value,
|
||||
set: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const extraProps: any = {};
|
||||
if (this.props.type === 'number') {
|
||||
// render as a HTML number input
|
||||
extraProps.type = 'number';
|
||||
|
||||
// step="any" allows any sort of float to be input, otherwise we're limited
|
||||
// to decimal
|
||||
extraProps.step = 'any';
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key="input"
|
||||
{...extraProps}
|
||||
compact={true}
|
||||
onChange={this.onNumberTextInputChange}
|
||||
onKeyDown={this.onNumberTextInputKeyDown}
|
||||
ref={this.onNumberTextRef}
|
||||
onBlur={this.onNumberTextBlur}
|
||||
value={this.props.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DataDescriptionState = {
|
||||
editing: boolean;
|
||||
origValue: any;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export default class DataDescription extends PureComponent<
|
||||
DataDescriptionProps,
|
||||
DataDescriptionState
|
||||
> {
|
||||
constructor(props: DataDescriptionProps, context: Object) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
editing: false,
|
||||
origValue: '',
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
|
||||
commit = (opts: DescriptionCommitOptions) => {
|
||||
const {path, setValue} = this.props;
|
||||
if (opts.keep && setValue && path) {
|
||||
const val = opts.value;
|
||||
this.setState({value: val});
|
||||
if (opts.set) {
|
||||
setValue(path, val);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.clear) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
origValue: '',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_renderEditing() {
|
||||
const {type, extra} = this.props;
|
||||
const {origValue, value} = this.state;
|
||||
|
||||
if (
|
||||
type === 'string' ||
|
||||
type === 'text' ||
|
||||
type === 'number' ||
|
||||
type === 'enum'
|
||||
) {
|
||||
return (
|
||||
<NumberTextEditor
|
||||
type={type}
|
||||
value={value}
|
||||
origValue={origValue}
|
||||
commit={this.commit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'color') {
|
||||
return <ColorEditor value={value} commit={this.commit} />;
|
||||
}
|
||||
|
||||
if (type === 'color_lite') {
|
||||
return (
|
||||
<ColorEditor
|
||||
value={value}
|
||||
colorSet={extra.colorSet}
|
||||
commit={this.commit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_hasEditUI() {
|
||||
const {type} = this.props;
|
||||
return (
|
||||
type === 'string' ||
|
||||
type === 'text' ||
|
||||
type === 'number' ||
|
||||
type === 'enum' ||
|
||||
type === 'color' ||
|
||||
type === 'color_lite'
|
||||
);
|
||||
}
|
||||
|
||||
onEditStart = () => {
|
||||
this.setState({
|
||||
editing: this._hasEditUI(),
|
||||
origValue: this.props.value,
|
||||
value: this.props.value,
|
||||
});
|
||||
};
|
||||
|
||||
render(): any {
|
||||
if (this.state.editing) {
|
||||
return this._renderEditing();
|
||||
} else {
|
||||
return (
|
||||
<DataDescriptionPreview
|
||||
type={this.props.type}
|
||||
value={this.props.value}
|
||||
extra={this.props.extra}
|
||||
editable={Boolean(this.props.setValue)}
|
||||
commit={this.commit}
|
||||
onEdit={this.onEditStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ColorEditor extends Component<{
|
||||
value: any;
|
||||
colorSet?: Array<string | number>;
|
||||
commit: (opts: DescriptionCommitOptions) => void;
|
||||
}> {
|
||||
onBlur = () => {
|
||||
this.props.commit({
|
||||
clear: true,
|
||||
keep: false,
|
||||
value: this.props.value,
|
||||
set: true,
|
||||
});
|
||||
};
|
||||
|
||||
onChange = ({
|
||||
hex,
|
||||
rgb: {a, b, g, r},
|
||||
}: {
|
||||
hex: string;
|
||||
rgb: {a: number; b: number; g: number; r: number};
|
||||
}) => {
|
||||
const prev = this.props.value;
|
||||
|
||||
let val;
|
||||
if (typeof prev === 'string') {
|
||||
if (a === 1) {
|
||||
// hex is fine and has an implicit 100% alpha
|
||||
val = hex;
|
||||
} else {
|
||||
// turn into a css rgba value
|
||||
val = `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
} else if (typeof prev === 'number') {
|
||||
// compute RRGGBBAA value
|
||||
val = (Math.round(a * 255) & 0xff) << 24;
|
||||
val |= (r & 0xff) << 16;
|
||||
val |= (g & 0xff) << 8;
|
||||
val |= b & 0xff;
|
||||
|
||||
const prevClear = ((prev >> 24) & 0xff) === 0;
|
||||
const onlyAlphaChanged = (prev & 0x00ffffff) === (val & 0x00ffffff);
|
||||
|
||||
if (!onlyAlphaChanged && prevClear) {
|
||||
val = 0xff000000 | (val & 0x00ffffff);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.commit({clear: false, keep: true, value: val, set: true});
|
||||
};
|
||||
|
||||
onChangeLite = ({
|
||||
rgb: {a, b, g, r},
|
||||
}: {
|
||||
rgb: {a: number; b: number; g: number; r: number};
|
||||
}) => {
|
||||
const prev = this.props.value;
|
||||
|
||||
if (typeof prev !== 'number') {
|
||||
return;
|
||||
}
|
||||
// compute RRGGBBAA value
|
||||
let val = (Math.round(a * 255) & 0xff) << 24;
|
||||
val |= (r & 0xff) << 16;
|
||||
val |= (g & 0xff) << 8;
|
||||
val |= b & 0xff;
|
||||
|
||||
this.props.commit({clear: false, keep: true, value: val, set: true});
|
||||
};
|
||||
|
||||
render() {
|
||||
const colorInfo = parseColor(this.props.value);
|
||||
if (!colorInfo) {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ColorPickerDescription>
|
||||
<DataDescriptionPreview
|
||||
type="color"
|
||||
value={this.props.value}
|
||||
extra={this.props.colorSet}
|
||||
editable={false}
|
||||
commit={this.props.commit}
|
||||
/>
|
||||
<Popover onDismiss={this.onBlur}>
|
||||
{this.props.colorSet ? (
|
||||
<CompactPicker
|
||||
color={colorInfo}
|
||||
colors={this.props.colorSet
|
||||
.filter(x => x != 0)
|
||||
.map(parseColor)
|
||||
.map(rgba => {
|
||||
if (!rgba) {
|
||||
return '';
|
||||
}
|
||||
return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`;
|
||||
})}
|
||||
onChange={(color: {
|
||||
hex: string;
|
||||
hsl: {
|
||||
a?: number;
|
||||
h: number;
|
||||
l: number;
|
||||
s: number;
|
||||
};
|
||||
rgb: {a?: number; b: number; g: number; r: number};
|
||||
}) => {
|
||||
this.onChangeLite({rgb: {...color.rgb, a: color.rgb.a || 0}});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SketchPicker
|
||||
color={colorInfo}
|
||||
presetColors={[
|
||||
colors.blue,
|
||||
colors.green,
|
||||
colors.red,
|
||||
colors.blueGrey,
|
||||
colors.slate,
|
||||
colors.aluminum,
|
||||
colors.seaFoam,
|
||||
colors.teal,
|
||||
colors.lime,
|
||||
colors.lemon,
|
||||
colors.orange,
|
||||
colors.tomato,
|
||||
colors.cherry,
|
||||
colors.pink,
|
||||
colors.grape,
|
||||
]}
|
||||
onChange={(color: {
|
||||
hex: string;
|
||||
hsl: {
|
||||
a?: number;
|
||||
h: number;
|
||||
l: number;
|
||||
s: number;
|
||||
};
|
||||
rgb: {a?: number; b: number; g: number; r: number};
|
||||
}) => {
|
||||
this.onChange({
|
||||
hex: color.hex,
|
||||
rgb: {...color.rgb, a: color.rgb.a || 1},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</ColorPickerDescription>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DataDescriptionPreview extends Component<{
|
||||
type: string;
|
||||
value: any;
|
||||
extra?: any;
|
||||
editable: boolean;
|
||||
commit: (opts: DescriptionCommitOptions) => void;
|
||||
onEdit?: () => void;
|
||||
}> {
|
||||
onClick = () => {
|
||||
const {onEdit} = this.props;
|
||||
if (this.props.editable && onEdit) {
|
||||
onEdit();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {type, value} = this.props;
|
||||
|
||||
const description = (
|
||||
<DataDescriptionContainer
|
||||
type={type}
|
||||
value={value}
|
||||
editable={this.props.editable}
|
||||
commit={this.props.commit}
|
||||
/>
|
||||
);
|
||||
|
||||
// booleans are always editable so don't require the onEditStart handler
|
||||
if (type === 'boolean') {
|
||||
return description;
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={this.onClick} role="button" tabIndex={-1}>
|
||||
{description}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseColor(
|
||||
val: string | number,
|
||||
):
|
||||
| {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
| undefined
|
||||
| null {
|
||||
if (typeof val === 'number') {
|
||||
const a = ((val >> 24) & 0xff) / 255;
|
||||
const r = (val >> 16) & 0xff;
|
||||
const g = (val >> 8) & 0xff;
|
||||
const b = val & 0xff;
|
||||
return {a, b, g, r};
|
||||
}
|
||||
if (typeof val !== 'string') {
|
||||
return;
|
||||
}
|
||||
if (val[0] !== '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove leading hash
|
||||
val = val.slice(1);
|
||||
|
||||
// only allow RGB and ARGB hex values
|
||||
if (val.length !== 3 && val.length !== 6 && val.length !== 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
// split every 2 characters
|
||||
const parts = val.match(/.{1,2}/g);
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get the alpha value
|
||||
let a = 1;
|
||||
|
||||
// extract alpha if passed AARRGGBB
|
||||
if (val.length === 8) {
|
||||
a = parseInt(parts.shift() || '0', 16) / 255;
|
||||
}
|
||||
|
||||
const size = val.length;
|
||||
const [r, g, b] = parts.map(num => {
|
||||
if (size === 3) {
|
||||
return parseInt(num + num, 16);
|
||||
} else {
|
||||
return parseInt(num, 16);
|
||||
}
|
||||
});
|
||||
|
||||
return {a, b, g, r};
|
||||
}
|
||||
|
||||
class DataDescriptionContainer extends Component<{
|
||||
type: string;
|
||||
value: any;
|
||||
editable: boolean;
|
||||
commit: (opts: DescriptionCommitOptions) => void;
|
||||
}> {
|
||||
onChangeCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.props.commit({
|
||||
clear: true,
|
||||
keep: true,
|
||||
value: e.target.checked,
|
||||
set: true,
|
||||
});
|
||||
};
|
||||
|
||||
render(): any {
|
||||
const {type, editable, value: val} = this.props;
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return <NumberValue>{Number(val)}</NumberValue>;
|
||||
|
||||
case 'color': {
|
||||
const colorInfo = parseColor(val);
|
||||
if (typeof val === 'number' && val === 0) {
|
||||
return <UndefinedValue>(not set)</UndefinedValue>;
|
||||
} else if (colorInfo) {
|
||||
const {a, b, g, r} = colorInfo;
|
||||
return [
|
||||
<ColorBox key="color-box" color={`rgba(${r}, ${g}, ${b}, ${a})`} />,
|
||||
<ColorValue key="value">
|
||||
rgba({r}, {g}, {b}, {a === 1 ? '1' : a.toFixed(2)})
|
||||
</ColorValue>,
|
||||
];
|
||||
} else {
|
||||
return <span>Malformed color</span>;
|
||||
}
|
||||
}
|
||||
|
||||
case 'color_lite': {
|
||||
const colorInfo = parseColor(val);
|
||||
if (typeof val === 'number' && val === 0) {
|
||||
return <UndefinedValue>(not set)</UndefinedValue>;
|
||||
} else if (colorInfo) {
|
||||
const {a, b, g, r} = colorInfo;
|
||||
return [
|
||||
<ColorBox key="color-box" color={`rgba(${r}, ${g}, ${b}, ${a})`} />,
|
||||
<ColorValue key="value">
|
||||
rgba({r}, {g}, {b}, {a === 1 ? '1' : a.toFixed(2)})
|
||||
</ColorValue>,
|
||||
];
|
||||
} else {
|
||||
return <span>Malformed color</span>;
|
||||
}
|
||||
}
|
||||
|
||||
case 'text':
|
||||
case 'string':
|
||||
if (val.startsWith('http://') || val.startsWith('https://')) {
|
||||
return (
|
||||
<>
|
||||
<Link href={val}>{val}</Link>
|
||||
<Glyph
|
||||
name="pencil"
|
||||
variant="outline"
|
||||
color={colors.light20}
|
||||
size={16}
|
||||
style={{cursor: 'pointer', marginLeft: 8}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return <StringValue>"{String(val || '')}"</StringValue>;
|
||||
}
|
||||
|
||||
case 'enum':
|
||||
return <StringValue>{String(val)}</StringValue>;
|
||||
|
||||
case 'boolean':
|
||||
return editable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(val)}
|
||||
disabled={!editable}
|
||||
onChange={this.onChangeCheckbox}
|
||||
/>
|
||||
) : (
|
||||
<StringValue>{String(val)}</StringValue>
|
||||
);
|
||||
|
||||
case 'undefined':
|
||||
return <UndefinedValue>undefined</UndefinedValue>;
|
||||
|
||||
case 'date':
|
||||
if (Object.prototype.toString.call(val) === '[object Date]') {
|
||||
return <span>{val.toString()}</span>;
|
||||
} else {
|
||||
return <span>{val}</span>;
|
||||
}
|
||||
|
||||
case 'null':
|
||||
return <NullValue>null</NullValue>;
|
||||
|
||||
case 'array':
|
||||
case 'object':
|
||||
// no description necessary as we'll typically wrap it in [] or {} which already denotes the
|
||||
// type
|
||||
return null;
|
||||
|
||||
case 'function':
|
||||
return (
|
||||
<span>
|
||||
<FunctionKeyword>function</FunctionKeyword>
|
||||
<FunctionName> {val.name}()</FunctionName>
|
||||
</span>
|
||||
);
|
||||
|
||||
case 'symbol':
|
||||
return <SymbolValue>Symbol()</SymbolValue>;
|
||||
|
||||
default:
|
||||
return <span>Unknown type "{type}"</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
650
desktop/app/src/ui/components/data-inspector/DataInspector.tsx
Normal file
650
desktop/app/src/ui/components/data-inspector/DataInspector.tsx
Normal file
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* 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 DataDescription from './DataDescription';
|
||||
import {MenuTemplate} from '../ContextMenu';
|
||||
import {Component} from 'react';
|
||||
import ContextMenu from '../ContextMenu';
|
||||
import Tooltip from '../Tooltip';
|
||||
import styled from '@emotion/styled';
|
||||
import createPaste from '../../../fb-stubs/createPaste';
|
||||
import {reportInteraction} from '../../../utils/InteractionTracker';
|
||||
import DataPreview, {DataValueExtractor, InspectorName} from './DataPreview';
|
||||
import {getSortedKeys} from './utils';
|
||||
import {colors} from '../colors';
|
||||
import {clipboard} from 'electron';
|
||||
import deepEqual from 'deep-equal';
|
||||
import React from 'react';
|
||||
import {TooltipOptions} from '../TooltipProvider';
|
||||
|
||||
export {DataValueExtractor} from './DataPreview';
|
||||
|
||||
const BaseContainer = styled.div<{depth?: number; disabled?: boolean}>(
|
||||
props => ({
|
||||
fontFamily: 'Menlo, monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: '17px',
|
||||
filter: props.disabled ? 'grayscale(100%)' : '',
|
||||
margin: props.depth === 0 ? '7.5px 0' : '0',
|
||||
paddingLeft: 10,
|
||||
userSelect: 'text',
|
||||
}),
|
||||
);
|
||||
BaseContainer.displayName = 'DataInspector:BaseContainer';
|
||||
|
||||
const RecursiveBaseWrapper = styled.span({
|
||||
color: colors.red,
|
||||
});
|
||||
RecursiveBaseWrapper.displayName = 'DataInspector:RecursiveBaseWrapper';
|
||||
|
||||
const Wrapper = styled.span({
|
||||
color: '#555',
|
||||
});
|
||||
Wrapper.displayName = 'DataInspector:Wrapper';
|
||||
|
||||
const PropertyContainer = styled.span({
|
||||
paddingTop: '2px',
|
||||
});
|
||||
PropertyContainer.displayName = 'DataInspector:PropertyContainer';
|
||||
|
||||
const ExpandControl = styled.span({
|
||||
color: '#6e6e6e',
|
||||
fontSize: 10,
|
||||
marginLeft: -11,
|
||||
marginRight: 5,
|
||||
whiteSpace: 'pre',
|
||||
});
|
||||
ExpandControl.displayName = 'DataInspector:ExpandControl';
|
||||
|
||||
const nameTooltipOptions: TooltipOptions = {
|
||||
position: 'toLeft',
|
||||
showTail: true,
|
||||
};
|
||||
|
||||
export type DataInspectorSetValue = (path: Array<string>, val: any) => void;
|
||||
|
||||
export type DataInspectorExpanded = {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
|
||||
export type DiffMetadataExtractor = (
|
||||
data: any,
|
||||
diff: any,
|
||||
key: string,
|
||||
) => Array<{
|
||||
data: any;
|
||||
diff?: any;
|
||||
status?: 'added' | 'removed';
|
||||
}>;
|
||||
|
||||
type DataInspectorProps = {
|
||||
/**
|
||||
* Object to inspect.
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* Object to compare with the provided `data` property.
|
||||
* Differences will be styled accordingly in the UI.
|
||||
*/
|
||||
diff?: any;
|
||||
/**
|
||||
* Current name of this value.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Current depth.
|
||||
*/
|
||||
depth: number;
|
||||
/**
|
||||
* An array containing the current location of the data relative to its root.
|
||||
*/
|
||||
path: Array<string>;
|
||||
/**
|
||||
* Whether to expand the root by default.
|
||||
*/
|
||||
expandRoot?: boolean;
|
||||
/**
|
||||
* An array of paths that are currently expanded.
|
||||
*/
|
||||
expanded: DataInspectorExpanded;
|
||||
/**
|
||||
* An optional callback that will explode a value into its type and value.
|
||||
* Useful for inspecting serialised data.
|
||||
*/
|
||||
extractValue?: DataValueExtractor;
|
||||
/**
|
||||
* Callback whenever the current expanded paths is changed.
|
||||
*/
|
||||
onExpanded?: ((expanded: DataInspectorExpanded) => void) | undefined | null;
|
||||
/**
|
||||
* Callback when a value is edited.
|
||||
*/
|
||||
setValue?: DataInspectorSetValue | undefined | null;
|
||||
/**
|
||||
* Whether all objects and arrays should be collapsed by default.
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* Ancestry of parent objects, used to avoid recursive objects.
|
||||
*/
|
||||
ancestry: Array<Object>;
|
||||
/**
|
||||
* Object of properties that will have tooltips
|
||||
*/
|
||||
tooltips?: any;
|
||||
};
|
||||
|
||||
const defaultValueExtractor: DataValueExtractor = (value: any) => {
|
||||
const type = typeof value;
|
||||
|
||||
if (type === 'number') {
|
||||
return {mutable: true, type: 'number', value};
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
return {mutable: true, type: 'string', value};
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return {mutable: true, type: 'boolean', value};
|
||||
}
|
||||
|
||||
if (type === 'undefined') {
|
||||
return {mutable: true, type: 'undefined', value};
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return {mutable: true, type: 'null', value};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {mutable: true, type: 'array', value};
|
||||
}
|
||||
|
||||
if (Object.prototype.toString.call(value) === '[object Date]') {
|
||||
return {mutable: true, type: 'date', value};
|
||||
}
|
||||
|
||||
if (type === 'object') {
|
||||
return {mutable: true, type: 'object', value};
|
||||
}
|
||||
};
|
||||
|
||||
const rootContextMenuCache: WeakMap<
|
||||
Object,
|
||||
Array<Electron.MenuItemConstructorOptions>
|
||||
> = new WeakMap();
|
||||
|
||||
function getRootContextMenu(
|
||||
data: Object,
|
||||
): Array<Electron.MenuItemConstructorOptions> {
|
||||
const cached = rootContextMenuCache.get(data);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stringValue = JSON.stringify(data, null, 2);
|
||||
const menu: Array<Electron.MenuItemConstructorOptions> = [
|
||||
{
|
||||
label: 'Copy entire tree',
|
||||
click: () => clipboard.writeText(stringValue),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Create paste',
|
||||
click: () => {
|
||||
createPaste(stringValue);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
rootContextMenuCache.set(data, menu);
|
||||
} else {
|
||||
console.error(
|
||||
'[data-inspector] Ignoring unsupported data type for cache: ',
|
||||
data,
|
||||
typeof data,
|
||||
);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
function isPureObject(obj: Object) {
|
||||
return (
|
||||
obj !== null &&
|
||||
Object.prototype.toString.call(obj) !== '[object Date]' &&
|
||||
typeof obj === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
const diffMetadataExtractor: DiffMetadataExtractor = (
|
||||
data: any,
|
||||
key: string,
|
||||
diff?: any,
|
||||
) => {
|
||||
if (diff == null) {
|
||||
return [{data: data[key]}];
|
||||
}
|
||||
|
||||
const val = data[key];
|
||||
const diffVal = diff[key];
|
||||
if (!data.hasOwnProperty(key)) {
|
||||
return [{data: diffVal, status: 'removed'}];
|
||||
}
|
||||
if (!diff.hasOwnProperty(key)) {
|
||||
return [{data: val, status: 'added'}];
|
||||
}
|
||||
|
||||
if (isPureObject(diffVal) && isPureObject(val)) {
|
||||
return [{data: val, diff: diffVal}];
|
||||
}
|
||||
|
||||
if (diffVal !== val) {
|
||||
// Check if there's a difference between the original value and
|
||||
// the value from the diff prop
|
||||
// The property name still exists, but the values may be different.
|
||||
return [
|
||||
{data: val, status: 'added'},
|
||||
{data: diffVal, status: 'removed'},
|
||||
];
|
||||
}
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(data, key) ? [{data: val}] : [];
|
||||
};
|
||||
|
||||
function isComponentExpanded(
|
||||
data: any,
|
||||
diffType: string,
|
||||
diffValue: any,
|
||||
isExpanded: boolean,
|
||||
) {
|
||||
if (isExpanded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (diffValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (diffType === 'object') {
|
||||
const sortedDataValues = Object.keys(data)
|
||||
.sort()
|
||||
.map(key => data[key]);
|
||||
const sortedDiffValues = Object.keys(diffValue)
|
||||
.sort()
|
||||
.map(key => diffValue[key]);
|
||||
if (JSON.stringify(sortedDataValues) !== JSON.stringify(sortedDiffValues)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (data !== diffValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* An expandable data inspector.
|
||||
*
|
||||
* This component is fairly low level. It's likely you're looking for
|
||||
* [`<ManagedDataInspector>`]().
|
||||
*/
|
||||
export default class DataInspector extends Component<DataInspectorProps> {
|
||||
static defaultProps: {
|
||||
expanded: DataInspectorExpanded;
|
||||
depth: number;
|
||||
path: Array<string>;
|
||||
ancestry: Array<Object>;
|
||||
} = {
|
||||
expanded: {},
|
||||
depth: 0,
|
||||
path: [],
|
||||
ancestry: [],
|
||||
};
|
||||
|
||||
interaction: (name: string, data?: any) => void;
|
||||
|
||||
constructor(props: DataInspectorProps) {
|
||||
super(props);
|
||||
this.interaction = reportInteraction('DataInspector', props.path.join(':'));
|
||||
}
|
||||
|
||||
static isExpandable(data: any) {
|
||||
return (
|
||||
typeof data === 'object' && data !== null && Object.keys(data).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: DataInspectorProps) {
|
||||
const {props} = this;
|
||||
|
||||
// check if any expanded paths effect this subtree
|
||||
if (nextProps.expanded !== props.expanded) {
|
||||
const path = nextProps.path.join('.');
|
||||
|
||||
for (const key in nextProps.expanded) {
|
||||
if (key.startsWith(path) === false) {
|
||||
// this key doesn't effect us
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextProps.expanded[key] !== props.expanded[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basic equality checks for the rest
|
||||
return (
|
||||
nextProps.data !== props.data ||
|
||||
nextProps.diff !== props.diff ||
|
||||
nextProps.name !== props.name ||
|
||||
nextProps.depth !== props.depth ||
|
||||
!deepEqual(nextProps.path, props.path) ||
|
||||
nextProps.onExpanded !== props.onExpanded ||
|
||||
nextProps.setValue !== props.setValue
|
||||
);
|
||||
}
|
||||
|
||||
isExpanded(pathParts: Array<string>) {
|
||||
const {expanded} = this.props;
|
||||
|
||||
// if we no expanded object then expand everything
|
||||
if (expanded == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const path = pathParts.join('.');
|
||||
|
||||
// check if there's a setting for this path
|
||||
if (Object.prototype.hasOwnProperty.call(expanded, path)) {
|
||||
return expanded[path];
|
||||
}
|
||||
|
||||
// check if all paths are collapsed
|
||||
if (this.props.collapsed === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// by default all items are expanded
|
||||
return true;
|
||||
}
|
||||
|
||||
setExpanded(pathParts: Array<string>, isExpanded: boolean) {
|
||||
const {expanded, onExpanded} = this.props;
|
||||
if (!onExpanded || !expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = pathParts.join('.');
|
||||
|
||||
onExpanded({
|
||||
...expanded,
|
||||
[path]: isExpanded,
|
||||
});
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
const isExpanded = this.isExpanded(this.props.path);
|
||||
this.interaction(isExpanded ? 'collapsed' : 'expanded');
|
||||
this.setExpanded(this.props.path, !isExpanded);
|
||||
};
|
||||
|
||||
extractValue = (data: any, depth: number) => {
|
||||
let res;
|
||||
|
||||
const {extractValue} = this.props;
|
||||
if (extractValue) {
|
||||
res = extractValue(data, depth);
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
res = defaultValueExtractor(data, depth);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
render(): any {
|
||||
const {
|
||||
data,
|
||||
diff,
|
||||
depth,
|
||||
expanded: expandedPaths,
|
||||
expandRoot,
|
||||
extractValue,
|
||||
name,
|
||||
onExpanded,
|
||||
path,
|
||||
ancestry,
|
||||
collapsed,
|
||||
tooltips,
|
||||
} = this.props;
|
||||
|
||||
// the data inspector makes values read only when setValue isn't set so we just need to set it
|
||||
// to null and the readOnly status will be propagated to all children
|
||||
let {setValue} = this.props;
|
||||
|
||||
const res = this.extractValue(data, depth);
|
||||
const resDiff = this.extractValue(diff, depth);
|
||||
|
||||
let type;
|
||||
let value;
|
||||
let extra;
|
||||
if (res) {
|
||||
if (!res.mutable) {
|
||||
setValue = null;
|
||||
}
|
||||
|
||||
({type, value, extra} = res);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ancestry.includes(value)) {
|
||||
return <RecursiveBaseWrapper>Recursive</RecursiveBaseWrapper>;
|
||||
}
|
||||
|
||||
const isExpandable = DataInspector.isExpandable(value);
|
||||
const isExpanded =
|
||||
isExpandable &&
|
||||
(resDiff != null
|
||||
? isComponentExpanded(
|
||||
value,
|
||||
resDiff.type,
|
||||
resDiff.value,
|
||||
expandRoot === true || this.isExpanded(path),
|
||||
)
|
||||
: expandRoot === true || this.isExpanded(path));
|
||||
|
||||
let expandGlyph = '';
|
||||
if (isExpandable) {
|
||||
if (isExpanded) {
|
||||
expandGlyph = '▼';
|
||||
} else {
|
||||
expandGlyph = '▶';
|
||||
}
|
||||
} else {
|
||||
if (depth !== 0) {
|
||||
expandGlyph = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
let propertyNodesContainer = null;
|
||||
if (isExpandable && isExpanded) {
|
||||
const propertyNodes = [];
|
||||
|
||||
// ancestry of children, including its owner object
|
||||
const childAncestry = ancestry.concat([value]);
|
||||
|
||||
const diffValue = diff && resDiff ? resDiff.value : null;
|
||||
|
||||
const keys = getSortedKeys({...value, ...diffValue});
|
||||
|
||||
const Added = styled.div({
|
||||
backgroundColor: colors.tealTint70,
|
||||
});
|
||||
const Removed = styled.div({
|
||||
backgroundColor: colors.cherryTint70,
|
||||
});
|
||||
|
||||
for (const key of keys) {
|
||||
const diffMetadataArr = diffMetadataExtractor(value, key, diffValue);
|
||||
for (const metadata of diffMetadataArr) {
|
||||
const dataInspectorNode = (
|
||||
<DataInspector
|
||||
ancestry={childAncestry}
|
||||
extractValue={extractValue}
|
||||
setValue={setValue}
|
||||
expanded={expandedPaths}
|
||||
collapsed={collapsed}
|
||||
onExpanded={onExpanded}
|
||||
path={path.concat(key)}
|
||||
depth={depth + 1}
|
||||
key={key}
|
||||
name={key}
|
||||
data={metadata.data}
|
||||
diff={metadata.diff}
|
||||
tooltips={tooltips}
|
||||
/>
|
||||
);
|
||||
|
||||
switch (metadata.status) {
|
||||
case 'added':
|
||||
propertyNodes.push(<Added key={key}>{dataInspectorNode}</Added>);
|
||||
break;
|
||||
case 'removed':
|
||||
propertyNodes.push(
|
||||
<Removed key={key}>{dataInspectorNode}</Removed>,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
propertyNodes.push(dataInspectorNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
propertyNodesContainer = propertyNodes;
|
||||
}
|
||||
|
||||
if (expandRoot === true) {
|
||||
return (
|
||||
<ContextMenu component="span" items={getRootContextMenu(data)}>
|
||||
{propertyNodesContainer}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// create name components
|
||||
const nameElems = [];
|
||||
if (typeof name !== 'undefined') {
|
||||
nameElems.push(
|
||||
<Tooltip
|
||||
title={tooltips != null && tooltips[name]}
|
||||
key="name"
|
||||
options={nameTooltipOptions}>
|
||||
<InspectorName>{name}</InspectorName>
|
||||
</Tooltip>,
|
||||
);
|
||||
nameElems.push(<span key="sep">: </span>);
|
||||
}
|
||||
|
||||
// create description or preview
|
||||
let descriptionOrPreview;
|
||||
if (isExpanded || !isExpandable) {
|
||||
descriptionOrPreview = (
|
||||
<DataDescription
|
||||
path={path}
|
||||
setValue={setValue}
|
||||
type={type}
|
||||
value={value}
|
||||
extra={extra}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
descriptionOrPreview = (
|
||||
<DataPreview
|
||||
type={type}
|
||||
value={value}
|
||||
extractValue={this.extractValue}
|
||||
depth={depth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
descriptionOrPreview = (
|
||||
<span>
|
||||
{nameElems}
|
||||
{descriptionOrPreview}
|
||||
</span>
|
||||
);
|
||||
|
||||
let wrapperStart;
|
||||
let wrapperEnd;
|
||||
if (isExpanded) {
|
||||
if (type === 'object') {
|
||||
wrapperStart = <Wrapper>{'{'}</Wrapper>;
|
||||
wrapperEnd = <Wrapper>{'}'}</Wrapper>;
|
||||
}
|
||||
|
||||
if (type === 'array') {
|
||||
wrapperStart = <Wrapper>{'['}</Wrapper>;
|
||||
wrapperEnd = <Wrapper>{']'}</Wrapper>;
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenuItems: MenuTemplate = [];
|
||||
|
||||
if (isExpandable) {
|
||||
contextMenuItems.push(
|
||||
{
|
||||
label: isExpanded ? 'Collapse' : 'Expand',
|
||||
click: this.handleClick,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
contextMenuItems.push(
|
||||
{
|
||||
label: 'Copy',
|
||||
click: () =>
|
||||
clipboard.writeText((window.getSelection() || '').toString()),
|
||||
},
|
||||
{
|
||||
label: 'Copy value',
|
||||
click: () => clipboard.writeText(JSON.stringify(data, null, 2)),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseContainer
|
||||
depth={depth}
|
||||
disabled={
|
||||
Boolean(this.props.setValue) === true && Boolean(setValue) === false
|
||||
}>
|
||||
<ContextMenu component="span" items={contextMenuItems}>
|
||||
<PropertyContainer
|
||||
onClick={isExpandable ? this.handleClick : undefined}>
|
||||
{expandedPaths && <ExpandControl>{expandGlyph}</ExpandControl>}
|
||||
{descriptionOrPreview}
|
||||
{wrapperStart}
|
||||
</PropertyContainer>
|
||||
</ContextMenu>
|
||||
{propertyNodesContainer}
|
||||
{wrapperEnd}
|
||||
</BaseContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
130
desktop/app/src/ui/components/data-inspector/DataPreview.tsx
Executable file
130
desktop/app/src/ui/components/data-inspector/DataPreview.tsx
Executable file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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 DataDescription from './DataDescription';
|
||||
import styled from '@emotion/styled';
|
||||
import {getSortedKeys} from './utils';
|
||||
import {PureComponent} from 'react';
|
||||
import React from 'react';
|
||||
import {colors} from '../colors';
|
||||
|
||||
export type DataValueExtractor = (
|
||||
value: any,
|
||||
depth: number,
|
||||
) =>
|
||||
| {
|
||||
mutable: boolean;
|
||||
type: string;
|
||||
value: any;
|
||||
extra?: any;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
export const InspectorName = styled.span({
|
||||
color: colors.grapeDark1,
|
||||
});
|
||||
InspectorName.displayName = 'DataInspector:InspectorName';
|
||||
|
||||
const PreviewContainer = styled.span({
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
PreviewContainer.displayName = 'DataPreview:PreviewContainer';
|
||||
|
||||
function intersperse(arr: Array<any>, sep: string) {
|
||||
if (arr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return arr.slice(1).reduce(
|
||||
(xs: any, x: any) => {
|
||||
return xs.concat([sep, x]);
|
||||
},
|
||||
[arr[0]],
|
||||
);
|
||||
}
|
||||
|
||||
export default class DataPreview extends PureComponent<{
|
||||
type: string;
|
||||
value: any;
|
||||
depth: number;
|
||||
extractValue: DataValueExtractor;
|
||||
maxProperties: number;
|
||||
}> {
|
||||
static defaultProps = {
|
||||
maxProperties: 5,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {depth, extractValue, type, value} = this.props;
|
||||
|
||||
if (type === 'array') {
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{'['}
|
||||
{intersperse(
|
||||
value.map((element: any, index: number) => {
|
||||
const res = extractValue(element, depth + 1);
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {type, value} = res;
|
||||
return (
|
||||
<DataDescription
|
||||
key={index}
|
||||
type={type}
|
||||
value={value}
|
||||
setValue={null}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
', ',
|
||||
)}
|
||||
{']'}
|
||||
</PreviewContainer>
|
||||
);
|
||||
} else if (type === 'date') {
|
||||
return <span>{value.toString()}</span>;
|
||||
} else if (type === 'object') {
|
||||
const propertyNodes = [];
|
||||
|
||||
const keys = getSortedKeys(value);
|
||||
let i = 0;
|
||||
for (const key of keys) {
|
||||
let ellipsis;
|
||||
i++;
|
||||
if (i >= this.props.maxProperties) {
|
||||
ellipsis = <span key={'ellipsis'}>…</span>;
|
||||
}
|
||||
|
||||
propertyNodes.push(
|
||||
<span key={key}>
|
||||
<InspectorName>{key}</InspectorName>
|
||||
{ellipsis}
|
||||
</span>,
|
||||
);
|
||||
|
||||
if (ellipsis) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{'{'}
|
||||
{intersperse(propertyNodes, ', ')}
|
||||
{'}'}
|
||||
</PreviewContainer>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 {DataInspectorExpanded} from './DataInspector';
|
||||
import {PureComponent} from 'react';
|
||||
import DataInspector from './DataInspector';
|
||||
import React from 'react';
|
||||
import {DataValueExtractor} from './DataPreview';
|
||||
|
||||
type ManagedDataInspectorProps = {
|
||||
/**
|
||||
* Object to inspect.
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* Object to compare with the provided `data` property.
|
||||
* Differences will be styled accordingly in the UI.
|
||||
*/
|
||||
diff?: any;
|
||||
/**
|
||||
* Whether to expand the root by default.
|
||||
*/
|
||||
expandRoot?: boolean;
|
||||
/**
|
||||
* An optional callback that will explode a value into its type and value.
|
||||
* Useful for inspecting serialised data.
|
||||
*/
|
||||
extractValue?: DataValueExtractor;
|
||||
/**
|
||||
* Callback when a value is edited.
|
||||
*/
|
||||
setValue?: (path: Array<string>, val: any) => void;
|
||||
/**
|
||||
* Whether all objects and arrays should be collapsed by default.
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* Object of all properties that will have tooltips
|
||||
*/
|
||||
tooltips?: Object;
|
||||
};
|
||||
|
||||
type ManagedDataInspectorState = {
|
||||
expanded: DataInspectorExpanded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around `DataInspector` that handles expanded state.
|
||||
*
|
||||
* If you require lower level access to the state then use `DataInspector`
|
||||
* directly.
|
||||
*/
|
||||
export default class ManagedDataInspector extends PureComponent<
|
||||
ManagedDataInspectorProps,
|
||||
ManagedDataInspectorState
|
||||
> {
|
||||
constructor(props: ManagedDataInspectorProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
expanded: {},
|
||||
};
|
||||
}
|
||||
|
||||
onExpanded = (expanded: DataInspectorExpanded) => {
|
||||
this.setState({expanded});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DataInspector
|
||||
data={this.props.data}
|
||||
diff={this.props.diff}
|
||||
extractValue={this.props.extractValue}
|
||||
setValue={this.props.setValue}
|
||||
expanded={this.state.expanded}
|
||||
onExpanded={this.onExpanded}
|
||||
expandRoot={this.props.expandRoot}
|
||||
collapsed={this.props.collapsed}
|
||||
tooltips={this.props.tooltips}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
14
desktop/app/src/ui/components/data-inspector/utils.tsx
Normal file
14
desktop/app/src/ui/components/data-inspector/utils.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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 naturalCompare from 'string-natural-compare';
|
||||
|
||||
export function getSortedKeys(obj: Object): Array<string> {
|
||||
return Object.keys(obj).sort(naturalCompare);
|
||||
}
|
||||
133
desktop/app/src/ui/components/desktop-toolbar.tsx
Normal file
133
desktop/app/src/ui/components/desktop-toolbar.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 styled from '@emotion/styled';
|
||||
import {colors, darkColors} from './colors';
|
||||
import React from 'react';
|
||||
|
||||
const DesktopDropdownContainer = styled.div({
|
||||
borderBottom: `1px solid ${darkColors.dividers}`,
|
||||
lineHeight: '25px',
|
||||
marginTop: 5,
|
||||
maxHeight: 600,
|
||||
minWidth: 180,
|
||||
overflow: 'auto',
|
||||
padding: 0,
|
||||
paddingBottom: 5,
|
||||
textAlign: 'left',
|
||||
width: 'auto',
|
||||
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
});
|
||||
DesktopDropdownContainer.displayName =
|
||||
'DesktopDropdown:DesktopDropdownContainer';
|
||||
|
||||
export function DesktopDropdown(props: {
|
||||
deactivate?: () => void;
|
||||
children?: any;
|
||||
}) {
|
||||
return (
|
||||
<DesktopDropdownContainer>
|
||||
{React.Children.map(props.children, child => {
|
||||
return (
|
||||
child &&
|
||||
React.cloneElement(child, {
|
||||
deactivate: props.deactivate,
|
||||
})
|
||||
);
|
||||
})}
|
||||
</DesktopDropdownContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const DesktopDropdownItemContainer = styled.div(
|
||||
(props: {onClick?: Function; onHover?: Function}) => ({
|
||||
listStyle: 'none',
|
||||
opacity: props.onClick || props.onHover ? 1 : 0.5,
|
||||
padding: '0 20px',
|
||||
'&:hover': {
|
||||
backgroundColor: props.onClick || props.onHover ? colors.highlight : '',
|
||||
color: props.onClick || props.onHover ? '#fff' : 'inherit',
|
||||
},
|
||||
}),
|
||||
);
|
||||
DesktopDropdownItemContainer.displayName =
|
||||
'DesktopDropdownItem:DesktopDropdownItemContainer';
|
||||
|
||||
type DesktopDropdownItemState = {hovered: boolean};
|
||||
|
||||
type DesktopDropdownItemProps = {
|
||||
onClick?: false | ((event: React.MouseEvent) => void);
|
||||
onHover?: false | (() => React.ReactNode);
|
||||
children?: React.ReactNode;
|
||||
deactivate?: () => void;
|
||||
};
|
||||
|
||||
export class DesktopDropdownItem extends React.Component<
|
||||
DesktopDropdownItemProps,
|
||||
DesktopDropdownItemState
|
||||
> {
|
||||
constructor(props: DesktopDropdownItemProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {hovered: false};
|
||||
}
|
||||
|
||||
onMouseEnter = () => {
|
||||
this.setState({hovered: true});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({hovered: false});
|
||||
};
|
||||
|
||||
onClick = (event: React.MouseEvent) => {
|
||||
const {deactivate, onClick} = this.props;
|
||||
if (typeof onClick === 'function') {
|
||||
if (deactivate) {
|
||||
deactivate();
|
||||
}
|
||||
onClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {hovered} = this.state;
|
||||
const {
|
||||
children,
|
||||
deactivate: _deactivate,
|
||||
onClick,
|
||||
onHover,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<DesktopDropdownItemContainer
|
||||
{...props}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onClick={typeof onClick === 'function' ? this.onClick : undefined}>
|
||||
{children}
|
||||
{hovered && typeof onHover === 'function' && onHover()}
|
||||
</DesktopDropdownItemContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const DesktopDropdownSelectedItem = styled(DesktopDropdownItem)({
|
||||
position: 'relative',
|
||||
|
||||
'&::before': {
|
||||
content: "'✔'",
|
||||
marginLeft: '-15px',
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
DesktopDropdownSelectedItem.displayName = 'DesktopDropdownSelectedItem';
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 {Elements, DecorateRow} from './elements';
|
||||
import {ContextMenuExtension} from 'flipper';
|
||||
import React from 'react';
|
||||
|
||||
export type ElementID = string;
|
||||
|
||||
export type ElementSearchResultSet = {
|
||||
query: string;
|
||||
matches: Set<ElementID>;
|
||||
};
|
||||
|
||||
export type ElementData = {
|
||||
[name: string]: {
|
||||
[key: string]:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| {
|
||||
__type__: string;
|
||||
value: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ElementAttribute = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ElementExtraInfo = {
|
||||
linkedNode?: string; // id of linked node in opposite tree
|
||||
expandWithParent?: boolean;
|
||||
};
|
||||
|
||||
export type Element = {
|
||||
id: ElementID;
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
children: Array<ElementID>;
|
||||
attributes: Array<ElementAttribute>;
|
||||
data: ElementData;
|
||||
decoration: string;
|
||||
extraInfo: ElementExtraInfo;
|
||||
};
|
||||
|
||||
export default class ElementsInspector extends Component<{
|
||||
onElementExpanded: (key: ElementID, deep: boolean) => void;
|
||||
onElementSelected: (key: ElementID) => void;
|
||||
onElementHovered:
|
||||
| ((key: ElementID | undefined | null) => any)
|
||||
| undefined
|
||||
| null;
|
||||
onValueChanged: ((path: Array<string>, val: any) => any) | undefined | null;
|
||||
selected: ElementID | undefined | null;
|
||||
focused?: ElementID | undefined | null;
|
||||
searchResults?: ElementSearchResultSet | undefined | null;
|
||||
root: ElementID | undefined | null;
|
||||
elements: {[key: string]: Element};
|
||||
useAppSidebar?: boolean;
|
||||
alternateRowColor?: boolean;
|
||||
contextMenuExtensions?: Array<ContextMenuExtension>;
|
||||
decorateRow?: DecorateRow;
|
||||
}> {
|
||||
static defaultProps = {
|
||||
alternateRowColor: true,
|
||||
};
|
||||
render() {
|
||||
const {
|
||||
selected,
|
||||
focused,
|
||||
elements,
|
||||
root,
|
||||
onElementExpanded,
|
||||
onElementSelected,
|
||||
onElementHovered,
|
||||
searchResults,
|
||||
alternateRowColor,
|
||||
contextMenuExtensions,
|
||||
decorateRow,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Elements
|
||||
onElementExpanded={onElementExpanded}
|
||||
onElementSelected={onElementSelected}
|
||||
onElementHovered={onElementHovered}
|
||||
selected={selected}
|
||||
focused={focused}
|
||||
searchResults={searchResults}
|
||||
root={root}
|
||||
elements={elements}
|
||||
alternateRowColor={alternateRowColor}
|
||||
contextMenuExtensions={contextMenuExtensions}
|
||||
decorateRow={decorateRow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
116
desktop/app/src/ui/components/elements-inspector/Visualizer.tsx
Normal file
116
desktop/app/src/ui/components/elements-inspector/Visualizer.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {Element, styled} from '../../../ui';
|
||||
|
||||
export function VisualizerPortal(props: {
|
||||
container: HTMLElement;
|
||||
highlightedElement: string | null;
|
||||
elements: {[key: string]: Element};
|
||||
screenshotURL: string;
|
||||
screenDimensions: {width: number; height: number};
|
||||
}) {
|
||||
const element: Element | 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>
|
||||
);
|
||||
}
|
||||
667
desktop/app/src/ui/components/elements-inspector/elements.tsx
Normal file
667
desktop/app/src/ui/components/elements-inspector/elements.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* 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 {ElementID, Element, ElementSearchResultSet} from './ElementsInspector';
|
||||
import {reportInteraction} from '../../../utils/InteractionTracker';
|
||||
import ContextMenu from '../ContextMenu';
|
||||
import {PureComponent, ReactElement} from 'react';
|
||||
import FlexRow from '../FlexRow';
|
||||
import FlexColumn from '../FlexColumn';
|
||||
import Glyph from '../Glyph';
|
||||
import {colors} from '../colors';
|
||||
import Text from '../Text';
|
||||
import styled from '@emotion/styled';
|
||||
import {clipboard, MenuItemConstructorOptions} from 'electron';
|
||||
import React, {MouseEvent, KeyboardEvent} from 'react';
|
||||
|
||||
const ROW_HEIGHT = 23;
|
||||
|
||||
const backgroundColor = (props: {
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
isQueryMatch: boolean;
|
||||
even: boolean;
|
||||
}) => {
|
||||
if (props.selected) {
|
||||
return colors.macOSTitleBarIconSelected;
|
||||
} else if (props.isQueryMatch) {
|
||||
return colors.purpleLight;
|
||||
} else if (props.focused) {
|
||||
return '#00CF52';
|
||||
} else if (props.even) {
|
||||
return colors.light02;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const backgroundColorHover = (props: {selected: boolean; focused: boolean}) => {
|
||||
if (props.selected) {
|
||||
return colors.macOSTitleBarIconSelected;
|
||||
} else if (props.focused) {
|
||||
return '#00CF52';
|
||||
} else {
|
||||
return '#EBF1FB';
|
||||
}
|
||||
};
|
||||
|
||||
const ElementsRowContainer = styled(ContextMenu)<any>(props => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: backgroundColor(props),
|
||||
color: props.selected || props.focused ? colors.white : colors.grapeDark3,
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
height: ROW_HEIGHT,
|
||||
minWidth: '100%',
|
||||
paddingLeft: (props.level - 1) * 12,
|
||||
paddingRight: 20,
|
||||
position: 'relative',
|
||||
|
||||
'& *': {
|
||||
color: props.selected || props.focused ? `${colors.white} !important` : '',
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: backgroundColorHover(props),
|
||||
},
|
||||
}));
|
||||
ElementsRowContainer.displayName = 'Elements:ElementsRowContainer';
|
||||
|
||||
const ElementsRowDecoration = styled(FlexRow)({
|
||||
flexShrink: 0,
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
marginRight: 4,
|
||||
position: 'relative',
|
||||
width: 16,
|
||||
top: -1,
|
||||
});
|
||||
ElementsRowDecoration.displayName = 'Elements:ElementsRowDecoration';
|
||||
|
||||
const ElementsLine = styled.div<{childrenCount: number}>(props => ({
|
||||
backgroundColor: colors.light20,
|
||||
height: props.childrenCount * ROW_HEIGHT - 4,
|
||||
position: 'absolute',
|
||||
right: 3,
|
||||
top: ROW_HEIGHT - 3,
|
||||
zIndex: 2,
|
||||
width: 2,
|
||||
borderRadius: '999em',
|
||||
}));
|
||||
ElementsLine.displayName = 'Elements:ElementsLine';
|
||||
|
||||
const DecorationImage = styled.img({
|
||||
height: 12,
|
||||
marginRight: 5,
|
||||
width: 12,
|
||||
});
|
||||
DecorationImage.displayName = 'Elements:DecorationImage';
|
||||
|
||||
const NoShrinkText = styled(Text)({
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
fontWeight: 400,
|
||||
});
|
||||
NoShrinkText.displayName = 'Elements:NoShrinkText';
|
||||
|
||||
const ElementsRowAttributeContainer = styled(NoShrinkText)({
|
||||
color: colors.dark80,
|
||||
fontWeight: 300,
|
||||
marginLeft: 5,
|
||||
});
|
||||
ElementsRowAttributeContainer.displayName =
|
||||
'Elements:ElementsRowAttributeContainer';
|
||||
|
||||
const ElementsRowAttributeKey = styled.span({
|
||||
color: colors.tomato,
|
||||
});
|
||||
ElementsRowAttributeKey.displayName = 'Elements:ElementsRowAttributeKey';
|
||||
|
||||
const ElementsRowAttributeValue = styled.span({
|
||||
color: colors.slateDark3,
|
||||
});
|
||||
ElementsRowAttributeValue.displayName = 'Elements:ElementsRowAttributeValue';
|
||||
|
||||
class PartialHighlight extends PureComponent<{
|
||||
selected: boolean;
|
||||
highlighted: string | undefined | null;
|
||||
content: string;
|
||||
}> {
|
||||
static HighlightedText = styled.span<{selected: boolean}>(props => ({
|
||||
backgroundColor: colors.lemon,
|
||||
color: props.selected ? `${colors.grapeDark3} !important` : 'auto',
|
||||
}));
|
||||
|
||||
render() {
|
||||
const {highlighted, content, selected} = this.props;
|
||||
if (
|
||||
content &&
|
||||
highlighted != null &&
|
||||
highlighted != '' &&
|
||||
content.toLowerCase().includes(highlighted.toLowerCase())
|
||||
) {
|
||||
const highlightStart = content
|
||||
.toLowerCase()
|
||||
.indexOf(highlighted.toLowerCase());
|
||||
const highlightEnd = highlightStart + highlighted.length;
|
||||
const before = content.substring(0, highlightStart);
|
||||
const match = content.substring(highlightStart, highlightEnd);
|
||||
const after = content.substring(highlightEnd);
|
||||
return (
|
||||
<span>
|
||||
{before}
|
||||
<PartialHighlight.HighlightedText selected={selected}>
|
||||
{match}
|
||||
</PartialHighlight.HighlightedText>
|
||||
{after}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return <span>{content}</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ElementsRowAttribute extends PureComponent<{
|
||||
name: string;
|
||||
value: string;
|
||||
matchingSearchQuery: string | undefined | null;
|
||||
selected: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {name, value, matchingSearchQuery, selected} = this.props;
|
||||
return (
|
||||
<ElementsRowAttributeContainer code={true}>
|
||||
<ElementsRowAttributeKey>{name}</ElementsRowAttributeKey>=
|
||||
<ElementsRowAttributeValue>
|
||||
<PartialHighlight
|
||||
content={value}
|
||||
highlighted={
|
||||
name === 'id' || name === 'addr' ? matchingSearchQuery : ''
|
||||
}
|
||||
selected={selected}
|
||||
/>
|
||||
</ElementsRowAttributeValue>
|
||||
</ElementsRowAttributeContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type FlatElement = {
|
||||
key: ElementID;
|
||||
element: Element;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type FlatElements = Array<FlatElement>;
|
||||
|
||||
type ElementsRowProps = {
|
||||
id: ElementID;
|
||||
level: number;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
matchingSearchQuery: string | undefined | null;
|
||||
isQueryMatch: boolean;
|
||||
element: Element;
|
||||
even: boolean;
|
||||
onElementSelected: (key: ElementID) => void;
|
||||
onElementExpanded: (key: ElementID, deep: boolean) => void;
|
||||
childrenCount: number;
|
||||
onElementHovered:
|
||||
| ((key: ElementID | undefined | null) => void)
|
||||
| undefined
|
||||
| null;
|
||||
style?: Object;
|
||||
contextMenuExtensions: Array<ContextMenuExtension>;
|
||||
decorateRow?: DecorateRow;
|
||||
};
|
||||
|
||||
type ElementsRowState = {
|
||||
hovered: boolean;
|
||||
};
|
||||
|
||||
class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
|
||||
constructor(props: ElementsRowProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {hovered: false};
|
||||
this.interaction = reportInteraction('ElementsRow', props.element.name);
|
||||
}
|
||||
|
||||
interaction: (name: string, data: any) => void;
|
||||
|
||||
getContextMenu = (): Array<MenuItemConstructorOptions> => {
|
||||
const {props} = this;
|
||||
let items: Array<MenuItemConstructorOptions> = [
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
click: () => {
|
||||
const attrs = props.element.attributes.reduce(
|
||||
(acc, val) => acc + ` ${val.name}=${val.value}`,
|
||||
'',
|
||||
);
|
||||
clipboard.writeText(`${props.element.name}${attrs}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: props.element.expanded ? 'Collapse' : 'Expand',
|
||||
click: () => {
|
||||
this.props.onElementExpanded(this.props.id, false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: props.element.expanded
|
||||
? 'Collapse all child elements'
|
||||
: 'Expand all child elements',
|
||||
click: () => {
|
||||
this.props.onElementExpanded(this.props.id, true);
|
||||
},
|
||||
},
|
||||
];
|
||||
items = items.concat(
|
||||
props.element.attributes.map(o => {
|
||||
return {
|
||||
label: `Copy ${o.name}`,
|
||||
click: () => {
|
||||
clipboard.writeText(o.value);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const extension of props.contextMenuExtensions) {
|
||||
items.push({
|
||||
label: extension.label,
|
||||
click: () => extension.click(this.props.id),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
this.props.onElementSelected(this.props.id);
|
||||
this.interaction('selected', {level: this.props.level});
|
||||
};
|
||||
|
||||
onDoubleClick = (event: MouseEvent<any>) => {
|
||||
this.props.onElementExpanded(this.props.id, event.altKey);
|
||||
};
|
||||
|
||||
onMouseEnter = () => {
|
||||
this.setState({hovered: true});
|
||||
if (this.props.onElementHovered) {
|
||||
this.props.onElementHovered(this.props.id);
|
||||
}
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({hovered: false});
|
||||
if (this.props.onElementHovered) {
|
||||
this.props.onElementHovered(null);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
element,
|
||||
id,
|
||||
level,
|
||||
selected,
|
||||
focused,
|
||||
style,
|
||||
even,
|
||||
matchingSearchQuery,
|
||||
decorateRow,
|
||||
} = this.props;
|
||||
const hasChildren = element.children && element.children.length > 0;
|
||||
|
||||
let arrow;
|
||||
if (hasChildren) {
|
||||
arrow = (
|
||||
<span onClick={this.onDoubleClick} role="button" tabIndex={-1}>
|
||||
<Glyph
|
||||
size={8}
|
||||
name={element.expanded ? 'chevron-down' : 'chevron-right'}
|
||||
color={selected || focused ? 'white' : colors.light80}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = element.attributes
|
||||
? element.attributes.map(attr => (
|
||||
<ElementsRowAttribute
|
||||
key={attr.name}
|
||||
name={attr.name}
|
||||
value={attr.value}
|
||||
matchingSearchQuery={matchingSearchQuery}
|
||||
selected={selected}
|
||||
/>
|
||||
))
|
||||
: [];
|
||||
|
||||
const decoration = decorateRow
|
||||
? decorateRow(element)
|
||||
: (() => {
|
||||
switch (element.decoration) {
|
||||
case 'litho':
|
||||
return <DecorationImage src="icons/litho-logo.png" />;
|
||||
case 'componentkit':
|
||||
return <DecorationImage src="icons/componentkit-logo.png" />;
|
||||
case 'accessibility':
|
||||
return <DecorationImage src="icons/accessibility.png" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// when we hover over or select an expanded element with children, we show a line from the
|
||||
// bottom of the element to the next sibling
|
||||
let line;
|
||||
const shouldShowLine =
|
||||
(selected || this.state.hovered) && hasChildren && element.expanded;
|
||||
if (shouldShowLine) {
|
||||
line = <ElementsLine childrenCount={this.props.childrenCount} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ElementsRowContainer
|
||||
buildItems={this.getContextMenu}
|
||||
key={id}
|
||||
level={level}
|
||||
selected={selected}
|
||||
focused={focused}
|
||||
matchingSearchQuery={matchingSearchQuery}
|
||||
even={even}
|
||||
onClick={this.onClick}
|
||||
onDoubleClick={this.onDoubleClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
isQueryMatch={this.props.isQueryMatch}
|
||||
style={style}>
|
||||
<ElementsRowDecoration>
|
||||
{line}
|
||||
{arrow}
|
||||
</ElementsRowDecoration>
|
||||
<NoShrinkText code={true}>
|
||||
{decoration}
|
||||
<PartialHighlight
|
||||
content={element.name}
|
||||
highlighted={matchingSearchQuery}
|
||||
selected={selected}
|
||||
/>
|
||||
</NoShrinkText>
|
||||
{attributes}
|
||||
</ElementsRowContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function containsKeyInSearchResults(
|
||||
searchResults: ElementSearchResultSet | undefined | null,
|
||||
key: ElementID,
|
||||
) {
|
||||
return searchResults != undefined && searchResults.matches.has(key);
|
||||
}
|
||||
|
||||
const ElementsContainer = styled(FlexColumn)({
|
||||
backgroundColor: colors.white,
|
||||
minHeight: '100%',
|
||||
minWidth: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
ElementsContainer.displayName = 'Elements:ElementsContainer';
|
||||
|
||||
const ElementsBox = styled(FlexColumn)({
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
});
|
||||
ElementsBox.displayName = 'Elements:ElementsBox';
|
||||
|
||||
export type DecorateRow = (e: Element) => ReactElement<any> | undefined | null;
|
||||
|
||||
type ElementsProps = {
|
||||
root: ElementID | undefined | null;
|
||||
selected: ElementID | undefined | null;
|
||||
focused?: ElementID | undefined | null;
|
||||
searchResults: ElementSearchResultSet | undefined | null;
|
||||
elements: {[key: string]: Element};
|
||||
onElementSelected: (key: ElementID) => void;
|
||||
onElementExpanded: (key: ElementID, deep: boolean) => void;
|
||||
onElementHovered:
|
||||
| ((key: ElementID | undefined | null) => void)
|
||||
| undefined
|
||||
| null;
|
||||
alternateRowColor?: boolean;
|
||||
contextMenuExtensions?: Array<ContextMenuExtension>;
|
||||
decorateRow?: DecorateRow;
|
||||
};
|
||||
|
||||
type ElementsState = {
|
||||
flatKeys: Array<ElementID>;
|
||||
flatElements: FlatElements;
|
||||
maxDepth: number;
|
||||
};
|
||||
|
||||
export type ContextMenuExtension = {
|
||||
label: string;
|
||||
click: (element: ElementID) => any;
|
||||
};
|
||||
|
||||
export class Elements extends PureComponent<ElementsProps, ElementsState> {
|
||||
static defaultProps = {
|
||||
alternateRowColor: true,
|
||||
};
|
||||
constructor(props: ElementsProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
flatElements: [],
|
||||
flatKeys: [],
|
||||
maxDepth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: ElementsProps) {
|
||||
const flatElements: FlatElements = [];
|
||||
const flatKeys: Array<ElementID> = [];
|
||||
|
||||
let maxDepth = 0;
|
||||
|
||||
function seed(key: ElementID, level: number) {
|
||||
const element = props.elements[key];
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
maxDepth = Math.max(maxDepth, level);
|
||||
|
||||
flatElements.push({
|
||||
element,
|
||||
key,
|
||||
level,
|
||||
});
|
||||
|
||||
flatKeys.push(key);
|
||||
|
||||
if (
|
||||
element.children != null &&
|
||||
element.children.length > 0 &&
|
||||
element.expanded
|
||||
) {
|
||||
for (const key of element.children) {
|
||||
seed(key, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (props.root != null) {
|
||||
seed(props.root, 1);
|
||||
}
|
||||
|
||||
return {flatElements, flatKeys, maxDepth};
|
||||
}
|
||||
|
||||
selectElement = (key: ElementID) => {
|
||||
this.props.onElementSelected(key);
|
||||
};
|
||||
|
||||
onKeyDown = (e: KeyboardEvent<any>) => {
|
||||
const {selected} = this.props;
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {props} = this;
|
||||
const {flatElements, flatKeys} = this.state;
|
||||
|
||||
const selectedIndex = flatKeys.indexOf(selected);
|
||||
if (selectedIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElement = props.elements[selected];
|
||||
if (!selectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === 'c' &&
|
||||
((e.metaKey && process.platform === 'darwin') ||
|
||||
(e.ctrlKey && process.platform !== 'darwin'))
|
||||
) {
|
||||
e.preventDefault();
|
||||
clipboard.writeText(selectedElement.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (selectedIndex === 0 || flatKeys.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.selectElement(flatKeys[selectedIndex - 1]);
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (selectedIndex === flatKeys.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.selectElement(flatKeys[selectedIndex + 1]);
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
if (selectedElement.expanded) {
|
||||
// unexpand
|
||||
props.onElementExpanded(selected, false);
|
||||
} else {
|
||||
// jump to parent
|
||||
let parentKey;
|
||||
const targetLevel = flatElements[selectedIndex].level - 1;
|
||||
for (let i = selectedIndex; i >= 0; i--) {
|
||||
const {level} = flatElements[i];
|
||||
if (level === targetLevel) {
|
||||
parentKey = flatKeys[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentKey) {
|
||||
this.selectElement(parentKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' && selectedElement.children.length > 0) {
|
||||
e.preventDefault();
|
||||
if (selectedElement.expanded) {
|
||||
// go to first child
|
||||
this.selectElement(selectedElement.children[0]);
|
||||
} else {
|
||||
// expand
|
||||
props.onElementExpanded(selected, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
buildRow = (row: FlatElement, index: number) => {
|
||||
const {
|
||||
onElementExpanded,
|
||||
onElementHovered,
|
||||
onElementSelected,
|
||||
selected,
|
||||
focused,
|
||||
searchResults,
|
||||
contextMenuExtensions,
|
||||
decorateRow,
|
||||
} = this.props;
|
||||
const {flatElements} = this.state;
|
||||
|
||||
let childrenCount = 0;
|
||||
for (let i = index + 1; i < flatElements.length; i++) {
|
||||
const child = flatElements[i];
|
||||
if (child.level <= row.level) {
|
||||
break;
|
||||
} else {
|
||||
childrenCount++;
|
||||
}
|
||||
}
|
||||
|
||||
let isEven = false;
|
||||
if (this.props.alternateRowColor) {
|
||||
isEven = index % 2 === 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<ElementsRow
|
||||
level={row.level}
|
||||
id={row.key}
|
||||
key={row.key}
|
||||
even={isEven}
|
||||
onElementExpanded={onElementExpanded}
|
||||
onElementHovered={(key: string | null | undefined) => {
|
||||
onElementHovered && onElementHovered(key);
|
||||
}}
|
||||
onElementSelected={onElementSelected}
|
||||
selected={selected === row.key}
|
||||
focused={focused === row.key}
|
||||
matchingSearchQuery={
|
||||
searchResults && containsKeyInSearchResults(searchResults, row.key)
|
||||
? searchResults.query
|
||||
: null
|
||||
}
|
||||
isQueryMatch={containsKeyInSearchResults(searchResults, row.key)}
|
||||
element={row.element}
|
||||
childrenCount={childrenCount}
|
||||
contextMenuExtensions={contextMenuExtensions || []}
|
||||
decorateRow={decorateRow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ElementsBox>
|
||||
<ElementsContainer onKeyDown={this.onKeyDown} tabIndex={0}>
|
||||
{this.state.flatElements.map(this.buildRow)}
|
||||
</ElementsContainer>
|
||||
</ElementsBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
191
desktop/app/src/ui/components/elements-inspector/sidebar.tsx
Normal file
191
desktop/app/src/ui/components/elements-inspector/sidebar.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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 {Element} from './ElementsInspector';
|
||||
import {PluginClient} from '../../../plugin';
|
||||
import Client from '../../../Client';
|
||||
import {Logger} from '../../../fb-interfaces/Logger';
|
||||
import Panel from '../Panel';
|
||||
import ManagedDataInspector from '../data-inspector/ManagedDataInspector';
|
||||
import {Component} from 'react';
|
||||
import {Console} from '../console';
|
||||
import GK from '../../../fb-stubs/GK';
|
||||
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}>
|
||||
<ManagedDataInspector
|
||||
data={this.props.data}
|
||||
setValue={this.props.onValueChanged ? this.setValue : undefined}
|
||||
extractValue={this.extractValue}
|
||||
expandRoot={true}
|
||||
collapsed={true}
|
||||
tooltips={this.props.tooltips}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
element: Element | undefined | null;
|
||||
tooltips?: Object;
|
||||
onValueChanged: OnValueChanged | undefined | null;
|
||||
client: PluginClient;
|
||||
realClient: Client;
|
||||
logger: Logger;
|
||||
extensions?: Array<Function>;
|
||||
};
|
||||
|
||||
type State = {
|
||||
isConsoleEnabled: boolean;
|
||||
};
|
||||
|
||||
export class InspectorSidebar extends Component<Props, State> {
|
||||
state = {
|
||||
isConsoleEnabled: false,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.checkIfConsoleIsEnabled();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.client !== this.props.client) {
|
||||
this.checkIfConsoleIsEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
checkIfConsoleIsEnabled() {
|
||||
this.props.client
|
||||
.call('isConsoleEnabled')
|
||||
.then((result: {isEnabled: boolean}) => {
|
||||
this.setState({isConsoleEnabled: result.isEnabled});
|
||||
});
|
||||
}
|
||||
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (GK.get('sonar_show_console_plugin') && this.state.isConsoleEnabled) {
|
||||
sections.push(
|
||||
<Panel heading="JS Console" floating={false} grow={false}>
|
||||
<Console client={this.props.client} getContext={() => element.id} />
|
||||
</Panel>,
|
||||
);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
}
|
||||
87
desktop/app/src/ui/components/filter/FilterRow.tsx
Normal file
87
desktop/app/src/ui/components/filter/FilterRow.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 {Filter} from './types';
|
||||
import React, {PureComponent} from 'react';
|
||||
import ContextMenu from '../ContextMenu';
|
||||
import textContent from '../../../utils/textContent';
|
||||
import styled from '@emotion/styled';
|
||||
import {colors} from '../colors';
|
||||
|
||||
const FilterText = styled.div({
|
||||
display: 'flex',
|
||||
alignSelf: 'baseline',
|
||||
userSelect: 'none',
|
||||
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/app/src/ui/components/filter/types.tsx
Normal file
26
desktop/app/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;
|
||||
};
|
||||
252
desktop/app/src/ui/components/searchable/FilterToken.tsx
Normal file
252
desktop/app/src/ui/components/searchable/FilterToken.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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 {colors} from '../colors';
|
||||
import electron, {MenuItemConstructorOptions} from 'electron';
|
||||
import React from 'react';
|
||||
import {ColorProperty} from 'csstype';
|
||||
|
||||
const Token = styled(Text)<{focused?: boolean; color?: ColorProperty}>(
|
||||
props => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: props.focused
|
||||
? colors.macOSHighlightActive
|
||||
: props.color || colors.macOSHighlight,
|
||||
borderRadius: 4,
|
||||
marginRight: 4,
|
||||
padding: 4,
|
||||
paddingLeft: 6,
|
||||
height: 21,
|
||||
color: props.focused ? 'white' : 'inherit',
|
||||
'&:active': {
|
||||
backgroundColor: colors.macOSHighlightActive,
|
||||
color: colors.white,
|
||||
},
|
||||
'&: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,
|
||||
textTransform: 'capitalize',
|
||||
lineHeight: '21px',
|
||||
'&:after': {
|
||||
content: props.type === 'exclude' ? '"≠"' : '"="',
|
||||
paddingLeft: 5,
|
||||
position: 'absolute',
|
||||
top: -1,
|
||||
right: 0,
|
||||
fontSize: 14,
|
||||
},
|
||||
'&:active:after': {
|
||||
backgroundColor: colors.macOSHighlightActive,
|
||||
},
|
||||
}));
|
||||
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 ? colors.white : '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> {
|
||||
_ref?: Element | null;
|
||||
|
||||
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<MenuItemConstructorOptions> = [];
|
||||
|
||||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
const menu = electron.remote.Menu.buildFromTemplate(menuTemplate);
|
||||
if (this._ref) {
|
||||
const {bottom, left} = this._ref.getBoundingClientRect();
|
||||
|
||||
menu.popup({
|
||||
window: electron.remote.getCurrentWindow(),
|
||||
// @ts-ignore: async is private API
|
||||
async: true,
|
||||
// Note: Electron requires the x/y parameters to be integer values for marshalling
|
||||
x: Math.round(left),
|
||||
y: Math.round(bottom + 8),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 = {
|
||||
type: 'enum',
|
||||
...filter,
|
||||
value,
|
||||
};
|
||||
this.props.onReplace(index, newFilter);
|
||||
}
|
||||
};
|
||||
|
||||
setRef = (ref: HTMLSpanElement | null) => {
|
||||
this._ref = ref;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Token
|
||||
key={`${filter.key}:${value}=${filter.type}`}
|
||||
tabIndex={-1}
|
||||
onMouseDown={this.onMouseDown}
|
||||
focused={this.props.focused}
|
||||
color={color}
|
||||
ref={this.setRef}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
543
desktop/app/src/ui/components/searchable/Searchable.tsx
Normal file
543
desktop/app/src/ui/components/searchable/Searchable.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* 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 Toolbar from '../Toolbar';
|
||||
import FlexRow from '../FlexRow';
|
||||
import Input from '../Input';
|
||||
import {colors} from '../colors';
|
||||
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.debounce';
|
||||
import ToggleButton from '../ToggleSwitch';
|
||||
import React from 'react';
|
||||
|
||||
const SearchBar = styled(Toolbar)({
|
||||
height: 42,
|
||||
padding: 6,
|
||||
});
|
||||
SearchBar.displayName = 'Searchable:SearchBar';
|
||||
|
||||
export const SearchBox = styled(FlexBox)({
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: '999em',
|
||||
border: `1px solid ${colors.light15}`,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
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,
|
||||
width: '100%',
|
||||
color: props.regex && !props.isValidInput ? colors.red : colors.black,
|
||||
'&::-webkit-input-placeholder': {
|
||||
color: colors.placeholder,
|
||||
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: colors.white,
|
||||
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(FlexRow)({
|
||||
marginLeft: 8,
|
||||
flexShrink: 0,
|
||||
});
|
||||
Actions.displayName = 'Searchable:Actions';
|
||||
|
||||
export type SearchableProps = {
|
||||
addFilter: (filter: Filter) => void;
|
||||
searchTerm: string;
|
||||
filters: Array<Filter>;
|
||||
allowRegexSearch?: boolean;
|
||||
allowBodySearch?: boolean;
|
||||
regexEnabled?: boolean;
|
||||
bodySearchEnabled?: 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;
|
||||
allowBodySearch: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
filters: Array<Filter>;
|
||||
focusedToken: number;
|
||||
searchTerm: string;
|
||||
hasFocus: boolean;
|
||||
regexEnabled: boolean;
|
||||
bodySearchEnabled: boolean;
|
||||
compiledRegex: RegExp | null | undefined;
|
||||
};
|
||||
|
||||
function compileRegex(s: string): RegExp | null {
|
||||
try {
|
||||
return new RegExp(s);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const Searchable = (
|
||||
Component: React.ComponentType<any>,
|
||||
): React.ComponentType<any> =>
|
||||
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,
|
||||
bodySearchEnabled: 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;
|
||||
bodySearchEnabled?: 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,
|
||||
bodySearchEnabled:
|
||||
savedState.bodySearchEnabled || this.state.bodySearchEnabled,
|
||||
compiledRegex: compileRegex(searchTerm),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (
|
||||
this.getTableKey() &&
|
||||
(prevState.searchTerm !== this.state.searchTerm ||
|
||||
prevState.regexEnabled != this.state.regexEnabled ||
|
||||
prevState.bodySearchEnabled != this.state.bodySearchEnabled ||
|
||||
prevState.filters !== this.state.filters)
|
||||
) {
|
||||
window.localStorage.setItem(
|
||||
this.getPersistKey(),
|
||||
JSON.stringify({
|
||||
searchTerm: this.state.searchTerm,
|
||||
filters: this.state.filters,
|
||||
regexEnabled: this.state.regexEnabled,
|
||||
bodySearchEnabled: this.state.bodySearchEnabled,
|
||||
}),
|
||||
);
|
||||
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][a-z0-9]*[!]?[:=][^\s]+)($|\s)/gi
|
||||
: /([a-z][a-z0-9]*[!]?[:=][^\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),
|
||||
});
|
||||
};
|
||||
|
||||
onBodySearchToggled = () => {
|
||||
this.setState({
|
||||
bodySearchEnabled: !this.state.bodySearchEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
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 [
|
||||
<SearchBar position="top" key="searchbar">
|
||||
<SearchBox tabIndex={-1}>
|
||||
<SearchIcon
|
||||
name="magnifying-glass"
|
||||
color={colors.macOSTitleBarIcon}
|
||||
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.allowBodySearch ? (
|
||||
<ToggleButton
|
||||
toggled={this.state.bodySearchEnabled}
|
||||
onClick={this.onBodySearchToggled}
|
||||
label={'Body'}
|
||||
tooltip={
|
||||
'Search request and response bodies (warning: this can be quite slow)'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{actions != null && <Actions>{actions}</Actions>}
|
||||
</SearchBar>,
|
||||
<Component
|
||||
{...props}
|
||||
key="table"
|
||||
addFilter={this.addFilter}
|
||||
searchTerm={this.state.searchTerm}
|
||||
regexEnabled={this.state.regexEnabled}
|
||||
bodySearchEnabled={this.state.bodySearchEnabled}
|
||||
filters={this.state.filters}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Higher-order-component that allows adding a searchbar on top of the wrapped
|
||||
* component. See SearchableManagedTable for usage with a table.
|
||||
*/
|
||||
export default Searchable;
|
||||
167
desktop/app/src/ui/components/searchable/SearchableTable.tsx
Normal file
167
desktop/app/src/ui/components/searchable/SearchableTable.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Filter} from '../filter/types';
|
||||
import ManagedTable, {ManagedTableProps} from '../table/ManagedTable';
|
||||
import {TableBodyRow} from '../table/types';
|
||||
import Searchable, {SearchableProps} from './Searchable';
|
||||
import React, {PureComponent} from 'react';
|
||||
import textContent from '../../../utils/textContent';
|
||||
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;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function rowMatchesSearchTerm(
|
||||
searchTerm: string,
|
||||
isRegex: boolean,
|
||||
isBodySearchEnabled: 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 (isBodySearchEnabled) {
|
||||
if (row.requestBody) {
|
||||
rowValues.push(row.requestBody);
|
||||
}
|
||||
if (row.responseBody) {
|
||||
rowValues.push(row.responseBody);
|
||||
}
|
||||
}
|
||||
if (isRegex) {
|
||||
return rowMatchesRegex(rowValues, searchTerm);
|
||||
}
|
||||
return rowValues.some(x =>
|
||||
x.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
const filterRowsFactory = (
|
||||
filters: Array<Filter>,
|
||||
searchTerm: string,
|
||||
regexSearch: boolean,
|
||||
bodySearch: boolean,
|
||||
) => (row: TableBodyRow): boolean =>
|
||||
rowMatchesFilters(filters, row) &&
|
||||
rowMatchesSearchTerm(searchTerm, regexSearch, bodySearch, 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.bodySearchEnabled || 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.bodySearchEnabled != this.props.bodySearchEnabled ||
|
||||
!deepEqual(this.props.filters, nextProps.filters)
|
||||
) {
|
||||
this.setState({
|
||||
filterRows: filterRowsFactory(
|
||||
nextProps.filters,
|
||||
nextProps.searchTerm,
|
||||
nextProps.regexEnabled || false,
|
||||
nextProps.bodySearchEnabled || 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}
|
||||
ref={innerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Table with filter and searchbar, supports all properties a ManagedTable
|
||||
* and Searchable supports.
|
||||
*/
|
||||
export default Searchable(SearchableManagedTable);
|
||||
@@ -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 {Filter} from '../filter/types';
|
||||
import {ManagedTableProps_immutable} from '../table/ManagedTable_immutable';
|
||||
import {TableBodyRow} from '../table/types';
|
||||
import Searchable, {SearchableProps} from './Searchable';
|
||||
import {PureComponent} from 'react';
|
||||
import ManagedTable_immutable from '../table/ManagedTable_immutable';
|
||||
import textContent from '../../../utils/textContent';
|
||||
import deepEqual from 'deep-equal';
|
||||
import React from 'react';
|
||||
|
||||
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_immutable &
|
||||
SearchableProps;
|
||||
|
||||
type State = {
|
||||
filterRows: (row: TableBodyRow) => boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
} else 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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function rowMatchesSearchTerm(
|
||||
searchTerm: string,
|
||||
isRegex: 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 (isRegex) {
|
||||
return rowMatchesRegex(rowValues, searchTerm);
|
||||
}
|
||||
return rowValues.some(x =>
|
||||
x.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
const filterRowsFactory = (
|
||||
filters: Array<Filter>,
|
||||
searchTerm: string,
|
||||
regexSearch: boolean,
|
||||
) => (row: TableBodyRow): boolean =>
|
||||
rowMatchesFilters(filters, row) &&
|
||||
rowMatchesSearchTerm(searchTerm, regexSearch, row);
|
||||
|
||||
class SearchableManagedTable_immutable extends PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
defaultFilters: [],
|
||||
};
|
||||
|
||||
state = {
|
||||
filterRows: filterRowsFactory(
|
||||
this.props.filters,
|
||||
this.props.searchTerm,
|
||||
this.props.regexEnabled || false,
|
||||
),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.defaultFilters.map(this.props.addFilter);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: Props) {
|
||||
if (
|
||||
nextProps.searchTerm !== this.props.searchTerm ||
|
||||
nextProps.regexEnabled != this.props.regexEnabled ||
|
||||
!deepEqual(this.props.filters, nextProps.filters)
|
||||
) {
|
||||
this.setState({
|
||||
filterRows: filterRowsFactory(
|
||||
nextProps.filters,
|
||||
nextProps.searchTerm,
|
||||
nextProps.regexEnabled || false,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
addFilter,
|
||||
searchTerm: _searchTerm,
|
||||
filters: _filters,
|
||||
innerRef,
|
||||
rows,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ManagedTable_immutable
|
||||
{...props}
|
||||
filter={this.state.filterRows}
|
||||
rows={rows.filter(this.state.filterRows)}
|
||||
onAddFilter={addFilter}
|
||||
ref={innerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Table with filter and searchbar, supports all properties a ManagedTable
|
||||
* and Searchable supports.
|
||||
*/
|
||||
export default Searchable(SearchableManagedTable_immutable);
|
||||
735
desktop/app/src/ui/components/table/ManagedTable.tsx
Normal file
735
desktop/app/src/ui/components/table/ManagedTable.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
/**
|
||||
* 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 {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 {clipboard, MenuItemConstructorOptions} from 'electron';
|
||||
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.debounce';
|
||||
import {DEFAULT_ROW_HEIGHT} from './types';
|
||||
import textContent from '../../../utils/textContent';
|
||||
import {notNull} from '../../../utils/typeUtils';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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,
|
||||
}));
|
||||
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() {
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
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.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) => {
|
||||
clipboard.writeText(
|
||||
[
|
||||
...(withHeader ? [this.getHeaderText()] : []),
|
||||
this.getSelectedText(),
|
||||
].join('\n'),
|
||||
);
|
||||
};
|
||||
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
const {highlightedRows} = this.state;
|
||||
if (highlightedRows.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
((e.metaKey && process.platform === 'darwin') ||
|
||||
(e.ctrlKey && process.platform !== 'darwin')) &&
|
||||
e.keyCode === 67
|
||||
) {
|
||||
this.onCopy(false);
|
||||
} else if (
|
||||
(e.keyCode === 38 || e.keyCode === 40) &&
|
||||
this.props.highlightableRows &&
|
||||
this.props.enableKeyboardNavigation
|
||||
) {
|
||||
// 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 (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(this.state.highlightedRows).pop()!;
|
||||
highlightedRows = new Set([
|
||||
...highlightedRows,
|
||||
...this.selectInRange(lastItemKey, row.key),
|
||||
]);
|
||||
} else {
|
||||
// single select
|
||||
this.state.highlightedRows.clear();
|
||||
this.state.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];
|
||||
clipboard.writeText(cellText);
|
||||
};
|
||||
|
||||
buildContextMenuItems: () => Array<MenuItemConstructorOptions> = () => {
|
||||
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) =>
|
||||
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}>
|
||||
{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});
|
||||
717
desktop/app/src/ui/components/table/ManagedTable_immutable.tsx
Normal file
717
desktop/app/src/ui/components/table/ManagedTable_immutable.tsx
Normal file
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* 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_immutable,
|
||||
TableBodyRow,
|
||||
TableOnAddFilter,
|
||||
} from './types';
|
||||
import {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 {clipboard, MenuItemConstructorOptions} from 'electron';
|
||||
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.debounce';
|
||||
import {DEFAULT_ROW_HEIGHT} from './types';
|
||||
import textContent from '../../../utils/textContent';
|
||||
import {notNull} from '../../../utils/typeUtils';
|
||||
|
||||
export type ManagedTableProps_immutable = {
|
||||
/**
|
||||
* Column definitions.
|
||||
*/
|
||||
columns: TableColumns;
|
||||
/**
|
||||
* Row definitions.
|
||||
*/
|
||||
rows: TableRows_immutable;
|
||||
/*
|
||||
* 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;
|
||||
};
|
||||
|
||||
type ManagedTableState = {
|
||||
highlightedRows: Set<string>;
|
||||
sortOrder?: TableRowSortOrder;
|
||||
columnOrder: TableColumnOrder;
|
||||
columnSizes: TableColumnSizes;
|
||||
shouldScrollToBottom: boolean;
|
||||
};
|
||||
|
||||
const Container = styled(FlexColumn)<{canOverflow?: boolean}>(props => ({
|
||||
overflow: props.canOverflow ? 'scroll' : 'visible',
|
||||
flexGrow: 1,
|
||||
}));
|
||||
Container.displayName = 'ManagedTable_immutable:Container';
|
||||
|
||||
const globalTableState: {[key: string]: TableColumnSizes} = {};
|
||||
|
||||
class ManagedTable extends React.Component<
|
||||
ManagedTableProps_immutable,
|
||||
ManagedTableState
|
||||
> {
|
||||
static defaultProps = {
|
||||
highlightableRows: true,
|
||||
multiHighlight: false,
|
||||
autoHeight: false,
|
||||
};
|
||||
|
||||
getTableKey = (): string => {
|
||||
return (
|
||||
'TABLE_COLUMNS_' +
|
||||
Object.keys(this.props.columns)
|
||||
.join('_')
|
||||
.toUpperCase()
|
||||
);
|
||||
};
|
||||
|
||||
state: ManagedTableState = {
|
||||
columnOrder:
|
||||
JSON.parse(window.localStorage.getItem(this.getTableKey()) || 'null') ||
|
||||
this.props.columnOrder ||
|
||||
Object.keys(this.props.columns).map(key => ({key, visible: true})),
|
||||
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),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: ManagedTableProps_immutable) {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.props.rows.size > nextProps.rows.size &&
|
||||
this.tableRef &&
|
||||
this.tableRef.current
|
||||
) {
|
||||
// rows were filtered, we need to recalculate heights
|
||||
this.tableRef.current.resetAfterIndex(0, true);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: ManagedTableProps_immutable,
|
||||
prevState: ManagedTableState,
|
||||
) {
|
||||
if (
|
||||
this.props.rows.size !== prevProps.rows.size &&
|
||||
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;
|
||||
}
|
||||
|
||||
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) => {
|
||||
clipboard.writeText(
|
||||
[
|
||||
...(withHeader ? [this.getHeaderText()] : []),
|
||||
this.getSelectedText(),
|
||||
].join('\n'),
|
||||
);
|
||||
};
|
||||
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
const {highlightedRows} = this.state;
|
||||
if (highlightedRows.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
((e.metaKey && process.platform === 'darwin') ||
|
||||
(e.ctrlKey && process.platform !== 'darwin')) &&
|
||||
e.keyCode === 67
|
||||
) {
|
||||
this.onCopy(false);
|
||||
} else if (
|
||||
(e.keyCode === 38 || e.keyCode === 40) &&
|
||||
this.props.highlightableRows
|
||||
) {
|
||||
// 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.size - 1,
|
||||
Math.max(0, e.keyCode === 38 ? lastItemIndex - 1 : lastItemIndex + 1),
|
||||
);
|
||||
if (!e.shiftKey) {
|
||||
highlightedRows.clear();
|
||||
}
|
||||
highlightedRows.add(rows.get(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.size > 1) {
|
||||
tableRef.scrollToItem(this.props.rows.size - 1);
|
||||
}
|
||||
}
|
||||
|
||||
onHighlight = (e: React.MouseEvent, row: TableBodyRow, index: number) => {
|
||||
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(this.state.highlightedRows).pop()!;
|
||||
highlightedRows = new Set([
|
||||
...highlightedRows,
|
||||
...this.selectInRange(lastItemKey, row.key),
|
||||
]);
|
||||
} else {
|
||||
// single select
|
||||
this.state.highlightedRows.clear();
|
||||
this.state.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.size; i++) {
|
||||
if (this.props.rows.get(i)!.key === fromKey) {
|
||||
startIndex = i;
|
||||
}
|
||||
if (this.props.rows.get(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.get(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.get(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];
|
||||
clipboard.writeText(cellText);
|
||||
};
|
||||
|
||||
buildContextMenuItems: () => MenuItemConstructorOptions[] = () => {
|
||||
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) =>
|
||||
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 {columnOrder, columnSizes, highlightedRows} = this.state;
|
||||
const columnKeys = columnOrder
|
||||
.map(k => (k.visible ? k.key : null))
|
||||
.filter(notNull);
|
||||
|
||||
const row = rows.get(index);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.key}
|
||||
columnSizes={columnSizes}
|
||||
columnKeys={columnKeys}
|
||||
onMouseDown={e => this.onHighlight(e, row, index)}
|
||||
onMouseEnter={e => this.onMouseEnterRow(e, row, index)}
|
||||
multiline={multiline}
|
||||
rowLineHeight={24}
|
||||
highlighted={highlightedRows.has(row.key)}
|
||||
row={row}
|
||||
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}>
|
||||
{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 ? (
|
||||
this.props.rows.map((_, index) => this.getRow({index, style: {}}))
|
||||
) : (
|
||||
<AutoSizer>
|
||||
{({width, height}) => (
|
||||
<ContextMenu
|
||||
buildItems={
|
||||
this.props.buildContextMenuItems ||
|
||||
this.buildContextMenuItems
|
||||
}>
|
||||
<List
|
||||
itemCount={rows.size}
|
||||
itemSize={index =>
|
||||
(rows.get(index) && rows.get(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});
|
||||
328
desktop/app/src/ui/components/table/TableHead.tsx
Normal file
328
desktop/app/src/ui/components/table/TableHead.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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 {normaliseColumnWidth, isPercentage} from './utils';
|
||||
import {PureComponent} from 'react';
|
||||
import ContextMenu from '../ContextMenu';
|
||||
import Interactive from '../Interactive';
|
||||
import styled from '@emotion/styled';
|
||||
import {colors} from '../colors';
|
||||
import FlexRow from '../FlexRow';
|
||||
import invariant from 'invariant';
|
||||
import {MenuItemConstructorOptions} from 'electron';
|
||||
import React from 'react';
|
||||
|
||||
const TableHeaderArrow = styled.span({
|
||||
float: 'right',
|
||||
});
|
||||
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
|
||||
|
||||
const TableHeaderColumnInteractive = styled(Interactive)({
|
||||
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 ${colors.sectionHeaderBorder}`,
|
||||
color: colors.light50,
|
||||
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: colors.white,
|
||||
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={true}
|
||||
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 = (): MenuItemConstructorOptions[] => {
|
||||
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 = normaliseColumnWidth(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>
|
||||
);
|
||||
}
|
||||
}
|
||||
203
desktop/app/src/ui/components/table/TableRow.tsx
Normal file
203
desktop/app/src/ui/components/table/TableRow.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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 {colors} from '../colors';
|
||||
import {normaliseColumnWidth} from './utils';
|
||||
import {DEFAULT_ROW_HEIGHT} from './types';
|
||||
import {
|
||||
FontWeightProperty,
|
||||
ColorProperty,
|
||||
JustifyContentProperty,
|
||||
BackgroundColorProperty,
|
||||
} from 'csstype';
|
||||
|
||||
type TableBodyRowContainerProps = {
|
||||
even?: boolean;
|
||||
zebra?: boolean;
|
||||
highlighted?: boolean;
|
||||
rowLineHeight?: number;
|
||||
multiline?: boolean;
|
||||
fontWeight?: FontWeightProperty;
|
||||
color?: ColorProperty;
|
||||
highlightOnHover?: boolean;
|
||||
backgroundColor?: BackgroundColorProperty;
|
||||
highlightedBackgroundColor?: BackgroundColorProperty;
|
||||
};
|
||||
|
||||
const backgroundColor = (props: TableBodyRowContainerProps) => {
|
||||
if (props.highlighted) {
|
||||
if (props.highlightedBackgroundColor) {
|
||||
return props.highlightedBackgroundColor;
|
||||
} else {
|
||||
return colors.macOSTitleBarIconSelected;
|
||||
}
|
||||
} else {
|
||||
if (props.backgroundColor) {
|
||||
return props.backgroundColor;
|
||||
} else if (props.even && props.zebra) {
|
||||
return colors.light02;
|
||||
} else {
|
||||
return 'transparent';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const TableBodyRowContainer = styled(FlexRow)<TableBodyRowContainerProps>(
|
||||
props => ({
|
||||
backgroundColor: backgroundColor(props),
|
||||
boxShadow: props.zebra ? 'none' : 'inset 0 -1px #E9EBEE',
|
||||
color: props.highlighted ? colors.white : props.color || undefined,
|
||||
'& *': {
|
||||
color: props.highlighted ? `${colors.white} !important` : undefined,
|
||||
},
|
||||
'& img': {
|
||||
backgroundColor: props.highlighted
|
||||
? `${colors.white} !important`
|
||||
: undefined,
|
||||
},
|
||||
height: props.multiline ? 'auto' : props.rowLineHeight,
|
||||
lineHeight: `${String(props.rowLineHeight || DEFAULT_ROW_HEIGHT)}px`,
|
||||
fontWeight: props.fontWeight,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
!props.highlighted && props.highlightOnHover ? colors.light02 : 'none',
|
||||
},
|
||||
}),
|
||||
);
|
||||
TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
|
||||
|
||||
const TableBodyColumnContainer = styled.div<{
|
||||
width?: any;
|
||||
multiline?: boolean;
|
||||
justifyContent: JustifyContentProperty;
|
||||
}>(
|
||||
(props: {
|
||||
width?: any;
|
||||
multiline?: boolean;
|
||||
justifyContent: JustifyContentProperty;
|
||||
}) => ({
|
||||
display: 'flex',
|
||||
flexShrink: props.width === 'flex' ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
padding: '0 8px',
|
||||
userSelect: 'none',
|
||||
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}
|
||||
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={normaliseColumnWidth(columnSizes[key])}>
|
||||
{isFilterable && onAddFilter != null ? (
|
||||
<FilterRow addFilter={onAddFilter} filterKey={key}>
|
||||
{value}
|
||||
</FilterRow>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</TableBodyColumnContainer>
|
||||
);
|
||||
})}
|
||||
</TableBodyRowContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {default as styled} from '@emotion/styled';
|
||||
import {colors} from '../colors';
|
||||
import {default as Text} from '../Text';
|
||||
import React from 'react';
|
||||
|
||||
export type Value =
|
||||
| {
|
||||
type: 'string';
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: 'boolean';
|
||||
value: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'integer' | 'float' | 'double' | 'number';
|
||||
value: number;
|
||||
}
|
||||
| {
|
||||
type: 'null';
|
||||
};
|
||||
|
||||
const NonWrappingText = styled(Text)({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
});
|
||||
NonWrappingText.displayName = 'TypeBasedValueRenderer:NonWrappingText';
|
||||
|
||||
const BooleanValue = styled(NonWrappingText)<{active?: boolean}>(props => ({
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: props.active ? colors.green : colors.red,
|
||||
marginRight: 5,
|
||||
marginTop: 1,
|
||||
},
|
||||
}));
|
||||
BooleanValue.displayName = 'TypeBasedValueRenderer:BooleanValue';
|
||||
|
||||
export function renderValue(val: Value) {
|
||||
switch (val.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<BooleanValue code={true} active={val.value}>
|
||||
{val.value.toString()}
|
||||
</BooleanValue>
|
||||
);
|
||||
case 'string':
|
||||
return <NonWrappingText>{val.value}</NonWrappingText>;
|
||||
case 'integer':
|
||||
case 'float':
|
||||
case 'double':
|
||||
case 'number':
|
||||
return <NonWrappingText>{val.value}</NonWrappingText>;
|
||||
case 'null':
|
||||
return <NonWrappingText>NULL</NonWrappingText>;
|
||||
default:
|
||||
return <NonWrappingText />;
|
||||
}
|
||||
}
|
||||
93
desktop/app/src/ui/components/table/types.tsx
Normal file
93
desktop/app/src/ui/components/table/types.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 {Filter} from '../filter/types';
|
||||
import {List} from 'immutable';
|
||||
import {BackgroundColorProperty} 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?: BackgroundColorProperty | undefined;
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
copyText?: string;
|
||||
requestBody?: string | null | undefined;
|
||||
responseBody?: string | null | undefined;
|
||||
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 TableRows_immutable = List<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/app/src/ui/components/table/utils.tsx
Normal file
33
desktop/app/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 normaliseColumnWidth(
|
||||
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] === '%';
|
||||
}
|
||||
174
desktop/app/src/ui/index.tsx
Normal file
174
desktop/app/src/ui/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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 ButtonNavigationGroup} from './components/ButtonNavigationGroup';
|
||||
export {default as ButtonGroup} from './components/ButtonGroup';
|
||||
export {default as ButtonGroupChain} from './components/ButtonGroupChain';
|
||||
|
||||
export {colors, darkColors, brandColors} from './components/colors';
|
||||
|
||||
export {default as Glyph, IconSize} from './components/Glyph';
|
||||
|
||||
export {default as LoadingIndicator} from './components/LoadingIndicator';
|
||||
|
||||
export {default as Popover} from './components/Popover';
|
||||
|
||||
// tables
|
||||
export {
|
||||
TableColumns,
|
||||
TableRows,
|
||||
TableRows_immutable,
|
||||
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 {default as ManagedTable_immutable} from './components/table/ManagedTable_immutable';
|
||||
export {ManagedTableProps_immutable} from './components/table/ManagedTable_immutable';
|
||||
|
||||
export {Value} from './components/table/TypeBasedValueRenderer';
|
||||
export {renderValue} from './components/table/TypeBasedValueRenderer';
|
||||
|
||||
export {
|
||||
DataValueExtractor,
|
||||
DataInspectorExpanded,
|
||||
} from './components/data-inspector/DataInspector';
|
||||
export {default as DataInspector} from './components/data-inspector/DataInspector';
|
||||
export {default as ManagedDataInspector} from './components/data-inspector/ManagedDataInspector';
|
||||
export {default as DataDescription} from './components/data-inspector/DataDescription';
|
||||
|
||||
// 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';
|
||||
|
||||
// code
|
||||
export {default as CodeBlock} from './components/CodeBlock';
|
||||
|
||||
// error
|
||||
export {default as ErrorBlock} from './components/ErrorBlock';
|
||||
export {ErrorBlockContainer} from './components/ErrorBlock';
|
||||
export {default as ErrorBoundary} from './components/ErrorBoundary';
|
||||
|
||||
// interactive components
|
||||
export {OrderableOrder} from './components/Orderable';
|
||||
export {default as Interactive} from './components/Interactive';
|
||||
export {default as Orderable} from './components/Orderable';
|
||||
export {default as VirtualList} from './components/VirtualList';
|
||||
|
||||
// base components
|
||||
export {Component, PureComponent} from 'react';
|
||||
|
||||
// context menus and dropdowns
|
||||
export {default as ContextMenuProvider} from './components/ContextMenuProvider';
|
||||
export {default as ContextMenu} from './components/ContextMenu';
|
||||
|
||||
// file
|
||||
export {FileListFile, FileListFiles} from './components/FileList';
|
||||
export {default as FileList} from './components/FileList';
|
||||
export {default as File} from './components/File';
|
||||
|
||||
// context menu items
|
||||
export {
|
||||
DesktopDropdownItem,
|
||||
DesktopDropdownSelectedItem,
|
||||
DesktopDropdown,
|
||||
} from './components/desktop-toolbar';
|
||||
|
||||
// utility elements
|
||||
export {default as View} from './components/View';
|
||||
export {default as ViewWithSize} from './components/ViewWithSize';
|
||||
export {default as Block} from './components/Block';
|
||||
export {default as FocusableBox} from './components/FocusableBox';
|
||||
export {default as Sidebar} from './components/Sidebar';
|
||||
export {default as SidebarLabel} from './components/SidebarLabel';
|
||||
export {default as Box} from './components/Box';
|
||||
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 {default as Toolbar, Spacer} from './components/Toolbar';
|
||||
export {default as Panel} from './components/Panel';
|
||||
export {default as Text} from './components/Text';
|
||||
export {default as TextParagraph} from './components/TextParagraph';
|
||||
export {default as Link} from './components/Link';
|
||||
export {default as PathBreadcrumbs} from './components/PathBreadcrumbs';
|
||||
export {default as ModalOverlay} from './components/ModalOverlay';
|
||||
export {default as Tooltip} from './components/Tooltip';
|
||||
export {default as TooltipProvider} from './components/TooltipProvider';
|
||||
export {default as ResizeSensor} from './components/ResizeSensor';
|
||||
export {default as StatusIndicator} from './components/StatusIndicator';
|
||||
|
||||
// typography
|
||||
export {default as HorizontalRule} from './components/HorizontalRule';
|
||||
export {default as VerticalRule} from './components/VerticalRule';
|
||||
export {default as Label} from './components/Label';
|
||||
export {default as Heading} from './components/Heading';
|
||||
|
||||
// filters
|
||||
export {Filter} from './components/filter/types';
|
||||
|
||||
export {default as MarkerTimeline} from './components/MarkerTimeline';
|
||||
|
||||
export {default as StackTrace} from './components/StackTrace';
|
||||
|
||||
export {
|
||||
SearchBox,
|
||||
SearchInput,
|
||||
SearchIcon,
|
||||
default as Searchable,
|
||||
} from './components/searchable/Searchable';
|
||||
export {default as SearchableTable} from './components/searchable/SearchableTable';
|
||||
export {default as SearchableTable_immutable} from './components/searchable/SearchableTable_immutable';
|
||||
export {SearchableProps} from './components/searchable/Searchable';
|
||||
|
||||
export {
|
||||
ElementID,
|
||||
ElementData,
|
||||
ElementAttribute,
|
||||
Element,
|
||||
ElementSearchResultSet,
|
||||
} from './components/elements-inspector/ElementsInspector';
|
||||
export {Elements} from './components/elements-inspector/elements';
|
||||
export {ContextMenuExtension} from './components/elements-inspector/elements';
|
||||
export {default as ElementsInspector} from './components/elements-inspector/ElementsInspector';
|
||||
export {InspectorSidebar} from './components/elements-inspector/sidebar';
|
||||
export {VisualizerPortal} from './components/elements-inspector/Visualizer';
|
||||
|
||||
export {Console} from './components/console';
|
||||
|
||||
export {default as Sheet} from './components/Sheet';
|
||||
export {StarButton} from './components/StarButton';
|
||||
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 {default as Bordered} from './components/Bordered';
|
||||
export {default as AlternatingRows} from './components/AlternatingRows';
|
||||
Reference in New Issue
Block a user