Initial commit 🎉

fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2
Co-authored-by: Sebastian McKenzie <sebmck@fb.com>
Co-authored-by: John Knox <jknox@fb.com>
Co-authored-by: Emil Sjölander <emilsj@fb.com>
Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import type {Filter} from 'sonar';
import {PureComponent} from 'react';
import Text from '../Text.js';
import styled from '../../styled/index.js';
import {findDOMNode} from 'react-dom';
import {colors} from '../colors.js';
import electron from 'electron';
const Token = Text.extends(
{
display: 'inline-flex',
alignItems: 'center',
backgroundColor: props =>
props.focused
? colors.macOSHighlightActive
: props.color || colors.macOSHighlight,
borderRadius: 4,
marginRight: 4,
padding: 4,
paddingLeft: 6,
height: 21,
color: props => (props.focused ? 'white' : 'inherit'),
'&:active': {
backgroundColor: colors.macOSHighlightActive,
color: colors.white,
},
'&:first-of-type': {
marginLeft: 3,
},
},
{
ignoreAttributes: ['focused', 'color'],
},
);
const Key = Text.extends(
{
position: 'relative',
fontWeight: 500,
paddingRight: 12,
textTransform: 'capitalize',
lineHeight: '21px',
'&:after': {
content: props => (props.type === 'exclude' ? '"≠"' : '"="'),
paddingLeft: 5,
position: 'absolute',
top: -1,
right: 0,
fontSize: 14,
},
'&:active:after': {
backgroundColor: colors.macOSHighlightActive,
},
},
{
ignoreAttributes: ['type', 'focused'],
},
);
const Value = Text.extends({
whiteSpace: 'nowrap',
maxWidth: 160,
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '21px',
paddingLeft: 3,
});
const Chevron = styled.view(
{
border: 0,
paddingLeft: 3,
paddingRight: 1,
marginRight: 0,
fontSize: 16,
backgroundColor: 'transparent',
position: 'relative',
top: -2,
height: 'auto',
lineHeight: 'initial',
color: props => (props.focused ? colors.white : 'inherit'),
'&:hover, &:active, &:focus': {
color: 'inherit',
border: 0,
backgroundColor: 'transparent',
},
},
{
ignoreAttributes: ['focused'],
},
);
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;
onMouseDown = () => {
if (
this.props.filter.persistent == null ||
this.props.filter.persistent === false
) {
this.props.onFocus(this.props.index);
}
this.showDetails();
};
showDetails = () => {
const menuTemplate = [];
if (this.props.filter.type === 'enum') {
menuTemplate.push(
...this.props.filter.enum.map(({value, label}) => ({
label,
click: () => this.changeEnum(value),
type: '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);
const {bottom, left} = this._ref ? this._ref.getBoundingClientRect() : {};
menu.popup(electron.remote.getCurrentWindow(), {
async: true,
x: parseInt(left, 10),
y: parseInt(bottom, 10) + 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: React.ElementRef<*>) => {
const element = findDOMNode(ref);
if (element instanceof HTMLElement) {
this._ref = element;
}
};
render() {
const {filter} = this.props;
let color;
let value = '';
if (filter.type === 'enum') {
const getEnum = value => 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}
innerRef={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}>
&#8964;
</Chevron>
</Token>
);
}
}

View File

@@ -0,0 +1,392 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import type {Filter} from 'sonar';
import {PureComponent} from 'react';
import Toolbar from '../Toolbar.js';
import FlexRow from '../FlexRow.js';
import Input from '../Input.js';
import {colors} from '../colors.js';
import Text from '../Text.js';
import FlexBox from '../FlexBox.js';
import Glyph from '../Glyph.js';
import FilterToken from './FilterToken.js';
import PropTypes from 'prop-types';
const SEARCHABLE_STORAGE_KEY = (key: string) => `SEARCHABLE_STORAGE_KEY_${key}`;
const SearchBar = Toolbar.extends({
height: 42,
padding: 6,
});
export const SearchBox = FlexBox.extends({
backgroundColor: colors.white,
borderRadius: '999em',
border: `1px solid ${colors.light15}`,
height: '100%',
width: '100%',
alignItems: 'center',
paddingLeft: 4,
});
export const SearchInput = Input.extends({
border: props => (props.focus ? '1px solid black' : 0),
padding: 0,
fontSize: '1em',
flexGrow: 1,
height: 'auto',
lineHeight: '100%',
marginLeft: 2,
width: '100%',
'&::-webkit-input-placeholder': {
color: colors.placeholder,
fontWeight: 300,
},
});
const Clear = Text.extends({
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)',
},
});
export const SearchIcon = Glyph.extends({
marginRight: 3,
marginLeft: 3,
marginTop: -1,
minWidth: 16,
});
const Actions = FlexRow.extends({
marginLeft: 8,
flexShrink: 0,
});
export type SearchableProps = {|
addFilter: (filter: Filter) => void,
searchTerm: string,
filters: Array<Filter>,
|};
type Props = {|
placeholder?: string,
actions: React.Node,
tableKey: string,
onFilterChange: (filters: Array<Filter>) => void,
defaultFilters: Array<Filter>,
|};
type State = {
filters: Array<Filter>,
focusedToken: number,
searchTerm: string,
hasFocus: boolean,
};
const Searchable = (
Component: React.ComponentType<any>,
): React.ComponentType<any> =>
class extends PureComponent<Props, State> {
static defaultProps = {
placeholder: 'Search...',
};
static contextTypes = {
plugin: PropTypes.string,
};
state = {
filters: [],
focusedToken: -1,
searchTerm: '',
hasFocus: false,
};
_inputRef: ?HTMLInputElement;
componentDidMount() {
window.document.addEventListener('keydown', this.onKeyDown);
const {defaultFilters} = this.props;
let savedState;
let key = this.context.plugin + this.props.tableKey;
try {
savedState = JSON.parse(
window.localStorage.getItem(SEARCHABLE_STORAGE_KEY(key)) || 'null',
);
} catch (e) {
window.localStorage.removeItem(SEARCHABLE_STORAGE_KEY(key));
}
if (savedState) {
if (this.props.onFilterChange != null) {
this.props.onFilterChange(savedState.filters);
}
if (defaultFilters != null) {
const savedStateFilters = savedState.filters;
defaultFilters.forEach(defaultFilter => {
const filterIndex = savedStateFilters.findIndex(
f => f.key === defaultFilter.key,
);
if (filterIndex > -1) {
const defaultFilter: Filter = defaultFilters[filterIndex];
if (defaultFilter.type === 'enum') {
savedStateFilters[filterIndex].enum = defaultFilter.enum;
}
const filters = new Set(
savedStateFilters[filterIndex].enum.map(filter => filter.value),
);
savedStateFilters[filterIndex].value = savedStateFilters[
filterIndex
].value.filter(value => filters.has(value));
}
});
}
this.setState({
searchTerm: savedState.searchTerm || '',
filters: savedState.filters || [],
});
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (
this.context.plugin &&
(prevState.searchTerm !== this.state.searchTerm ||
prevState.filters !== this.state.filters)
) {
let key = this.context.plugin + this.props.tableKey;
window.localStorage.setItem(
SEARCHABLE_STORAGE_KEY(key),
JSON.stringify({
searchTerm: this.state.searchTerm,
filters: this.state.filters,
}),
);
if (this.props.onFilterChange != null) {
this.props.onFilterChange(this.state.filters);
}
}
}
componentWillUnmount() {
window.document.removeEventListener('keydown', this.onKeyDown);
}
onKeyDown = (e: SyntheticKeyboardEvent<>) => {
const ctrlOrCmd = e =>
(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()) {
if (
this.state.focusedToken === -1 &&
this.state.searchTerm === '' &&
this._inputRef &&
!this.state.filters[this.state.filters.length - 1].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);
}
};
onChangeSearchTerm = (e: SyntheticInputEvent<HTMLInputElement>) =>
this.matchTags(e.target.value, false);
matchTags = (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, ...value] = filter.split(separator);
value = value.join(separator).trim();
let type = '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, '');
}
this.setState({searchTerm});
};
setInputRef = (ref: ?HTMLInputElement) => {
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];
if (
defaultFilter != null &&
defaultFilter.type === 'enum' &&
filters[filterIndex].type === 'enum'
) {
filters[filterIndex].enum = defaultFilter.enum;
}
this.setState({filters});
// filter for this key already exists
return;
}
// persistent filters are always at the front
const filters =
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});
hasFocus = (): boolean => {
return this.state.focusedToken !== -1 || this.state.hasFocus;
};
clear = () =>
this.setState({
filters: this.state.filters.filter(
f => f.persistent != null && f.persistent === true,
),
searchTerm: '',
});
render(): React.Node {
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}
innerRef={this.setInputRef}
onFocus={this.onInputFocus}
onBlur={this.onInputBlur}
/>
{(this.state.searchTerm || this.state.filters.length > 0) && (
<Clear onClick={this.clear}>&times;</Clear>
)}
</SearchBox>
{actions != null && <Actions>{actions}</Actions>}
</SearchBar>,
<Component
{...props}
key="table"
addFilter={this.addFilter}
searchTerm={this.state.searchTerm}
filters={this.state.filters}
/>,
];
}
};
export default Searchable;

View File

@@ -0,0 +1,107 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import type {ManagedTableProps, TableBodyRow, Filter} from 'sonar';
import type {SearchableProps} from './Searchable.js';
import {PureComponent} from 'react';
import ManagedTable from '../table/ManagedTable.js';
import textContent from '../../../utils/textContent.js';
import Searchable from './Searchable.js';
import deepEqual from 'deep-equal';
type Props = {|
...ManagedTableProps,
...SearchableProps,
innerRef?: (ref: React.ElementRef<*>) => void,
defaultFilters: Array<Filter>,
filter: empty,
filterValue: empty,
|};
type State = {
filterRows: (row: TableBodyRow) => boolean,
};
const filterRowsFactory = (filters: Array<Filter>, searchTerm: string) => (
row: TableBodyRow,
): boolean =>
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;
}
})
.reduce((acc, cv) => acc && cv, true) &&
(searchTerm != null && searchTerm.length > 0
? Object.keys(row.columns)
.map(key => textContent(row.columns[key].value))
.join('~~') // prevent from matching text spanning multiple columns
.toLowerCase()
.includes(searchTerm.toLowerCase())
: true);
class SearchableManagedTable extends PureComponent<Props, State> {
static defaultProps = {
defaultFilters: [],
};
state = {
filterRows: filterRowsFactory(this.props.filters, this.props.searchTerm),
};
componentDidMount() {
this.props.defaultFilters.map(this.props.addFilter);
}
componentWillReceiveProps(nextProps: Props) {
// ManagedTable is a PureComponent and does not update when this.filterRows
// would return a different value. This is why we update the funtion reference
// once the results of the function changed.
if (
nextProps.searchTerm !== this.props.searchTerm ||
!deepEqual(this.props.filters, nextProps.filters)
) {
this.setState({
filterRows: filterRowsFactory(nextProps.filters, nextProps.searchTerm),
});
}
}
render() {
const {
addFilter,
searchTerm: _searchTerm,
filters: _filters,
innerRef,
...props
} = this.props;
return (
// $FlowFixMe
<ManagedTable
{...props}
filter={this.state.filterRows}
onAddFilter={addFilter}
ref={innerRef}
/>
);
}
}
export default Searchable(SearchableManagedTable);