Summary: Some folks were missing the copy as text ManagedTable used to have, so introduced both the options to either copy as text (visible columns or custom copy handler) or as JSON Changelog: It is now possible to both copy as text or as JSON from data tables Reviewed By: jknoxville Differential Revision: D29712096 fbshipit-source-id: 27bd2e869a247bd0896ce2774c08651123fd531d
585 lines
15 KiB
TypeScript
585 lines
15 KiB
TypeScript
/**
|
|
* 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 {SearchableProps} from './ui';
|
|
import {Logger} from './fb-interfaces/Logger';
|
|
import {
|
|
Searchable,
|
|
Button,
|
|
ButtonGroup,
|
|
FlexBox,
|
|
FlexColumn,
|
|
FlexRow,
|
|
Glyph,
|
|
ContextMenu,
|
|
styled,
|
|
colors,
|
|
} from './ui';
|
|
import {PluginDefinition, DevicePluginMap, ClientPluginMap} from './plugin';
|
|
import {connect} from 'react-redux';
|
|
import React, {Component, Fragment} from 'react';
|
|
import {
|
|
PluginNotification,
|
|
updatePluginBlocklist,
|
|
updateCategoryBlocklist,
|
|
} from './reducers/notifications';
|
|
import {selectPlugin} from './reducers/connections';
|
|
import {State as StoreState} from './reducers/index';
|
|
import {textContent} from 'flipper-plugin';
|
|
import createPaste from './fb-stubs/createPaste';
|
|
import {getPluginTitle} from './utils/pluginUtils';
|
|
import {getFlipperLib} from 'flipper-plugin';
|
|
|
|
type OwnProps = {
|
|
onClear: () => void;
|
|
selectedID: string | null | undefined;
|
|
logger: Logger;
|
|
} & Partial<SearchableProps>;
|
|
|
|
type StateFromProps = {
|
|
activeNotifications: Array<PluginNotification>;
|
|
invalidatedNotifications: Array<PluginNotification>;
|
|
blocklistedPlugins: Array<string>;
|
|
blocklistedCategories: Array<string>;
|
|
devicePlugins: DevicePluginMap;
|
|
clientPlugins: ClientPluginMap;
|
|
};
|
|
|
|
type DispatchFromProps = {
|
|
selectPlugin: (payload: {
|
|
selectedPlugin: string | null;
|
|
selectedApp: string | null;
|
|
deepLinkPayload: unknown;
|
|
}) => any;
|
|
updatePluginBlocklist: (blocklist: Array<string>) => any;
|
|
updateCategoryBlocklist: (blocklist: Array<string>) => any;
|
|
};
|
|
|
|
type Props = OwnProps & StateFromProps & DispatchFromProps;
|
|
|
|
type State = {
|
|
selectedNotification: string | null | undefined;
|
|
};
|
|
|
|
const Content = styled(FlexColumn)({
|
|
padding: '0 10px',
|
|
backgroundColor: colors.light02,
|
|
overflow: 'scroll',
|
|
flexGrow: 1,
|
|
});
|
|
|
|
const Heading = styled(FlexBox)({
|
|
display: 'block',
|
|
alignItems: 'center',
|
|
marginTop: 15,
|
|
marginBottom: 5,
|
|
color: colors.macOSSidebarSectionTitle,
|
|
fontSize: 11,
|
|
fontWeight: 500,
|
|
textOverflow: 'ellipsis',
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
flexShrink: 0,
|
|
});
|
|
|
|
const NoContent = styled(FlexColumn)({
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
textAlign: 'center',
|
|
flexGrow: 1,
|
|
fontWeight: 500,
|
|
lineHeight: 2.5,
|
|
color: colors.light30,
|
|
});
|
|
|
|
class NotificationsTable extends Component<Props & SearchableProps, State> {
|
|
contextMenuItems = [{label: 'Clear all', click: this.props.onClear}];
|
|
state: State = {
|
|
selectedNotification: this.props.selectedID,
|
|
};
|
|
|
|
componentDidUpdate(prevProps: Props & SearchableProps) {
|
|
if (this.props.filters.length !== prevProps.filters.length) {
|
|
this.props.updatePluginBlocklist(
|
|
this.props.filters
|
|
.filter(
|
|
(f) => f.type === 'exclude' && f.key.toLowerCase() === 'plugin',
|
|
)
|
|
.map((f) => String(f.value)),
|
|
);
|
|
|
|
this.props.updateCategoryBlocklist(
|
|
this.props.filters
|
|
.filter(
|
|
(f) => f.type === 'exclude' && f.key.toLowerCase() === 'category',
|
|
)
|
|
.map((f) => String(f.value)),
|
|
);
|
|
}
|
|
|
|
if (
|
|
this.props.selectedID &&
|
|
prevProps.selectedID !== this.props.selectedID
|
|
) {
|
|
this.setState({
|
|
selectedNotification: this.props.selectedID,
|
|
});
|
|
}
|
|
}
|
|
|
|
onHidePlugin = (pluginId: string) => {
|
|
// add filter to searchbar
|
|
this.props.addFilter({
|
|
value: pluginId,
|
|
type: 'exclude',
|
|
key: 'plugin',
|
|
});
|
|
this.props.updatePluginBlocklist(
|
|
this.props.blocklistedPlugins.concat(pluginId),
|
|
);
|
|
};
|
|
|
|
onHideCategory = (category: string) => {
|
|
// add filter to searchbar
|
|
this.props.addFilter({
|
|
value: category,
|
|
type: 'exclude',
|
|
key: 'category',
|
|
});
|
|
this.props.updatePluginBlocklist(
|
|
this.props.blocklistedCategories.concat(category),
|
|
);
|
|
};
|
|
|
|
getFilter =
|
|
(): ((n: PluginNotification) => boolean) => (n: PluginNotification) => {
|
|
const searchTerm = this.props.searchTerm.toLowerCase();
|
|
|
|
// filter plugins
|
|
const blocklistedPlugins = new Set(
|
|
this.props.blocklistedPlugins.map((p) => p.toLowerCase()),
|
|
);
|
|
if (blocklistedPlugins.has(n.pluginId.toLowerCase())) {
|
|
return false;
|
|
}
|
|
|
|
// filter categories
|
|
const {category} = n.notification;
|
|
if (category) {
|
|
const blocklistedCategories = new Set(
|
|
this.props.blocklistedCategories.map((p) => p.toLowerCase()),
|
|
);
|
|
if (blocklistedCategories.has(category.toLowerCase())) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (searchTerm.length === 0) {
|
|
return true;
|
|
} else if (n.notification.title.toLowerCase().indexOf(searchTerm) > -1) {
|
|
return true;
|
|
} else if (
|
|
typeof n.notification.message === 'string' &&
|
|
n.notification.message.toLowerCase().indexOf(searchTerm) > -1
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
getPlugin = (id: string) =>
|
|
this.props.clientPlugins.get(id) || this.props.devicePlugins.get(id);
|
|
|
|
render() {
|
|
const activeNotifications = this.props.activeNotifications
|
|
.filter(this.getFilter())
|
|
.map((n: PluginNotification) => {
|
|
const {category} = n.notification;
|
|
|
|
return (
|
|
<NotificationItem
|
|
key={n.notification.id}
|
|
{...n}
|
|
plugin={this.getPlugin(n.pluginId)}
|
|
isSelected={this.state.selectedNotification === n.notification.id}
|
|
onHighlight={() =>
|
|
this.setState({selectedNotification: n.notification.id})
|
|
}
|
|
onClear={this.props.onClear}
|
|
onHidePlugin={() => this.onHidePlugin(n.pluginId)}
|
|
onHideCategory={
|
|
category ? () => this.onHideCategory(category) : undefined
|
|
}
|
|
selectPlugin={this.props.selectPlugin}
|
|
logger={this.props.logger}
|
|
/>
|
|
);
|
|
})
|
|
.reverse();
|
|
|
|
const invalidatedNotifications = this.props.invalidatedNotifications
|
|
.filter(this.getFilter())
|
|
.map((n: PluginNotification) => (
|
|
<NotificationItem
|
|
key={n.notification.id}
|
|
{...n}
|
|
plugin={this.getPlugin(n.pluginId)}
|
|
onClear={this.props.onClear}
|
|
inactive
|
|
/>
|
|
))
|
|
.reverse();
|
|
|
|
return (
|
|
<ContextMenu items={this.contextMenuItems} component={Content}>
|
|
{activeNotifications.length > 0 && (
|
|
<Fragment>
|
|
<Heading>Active notifications</Heading>
|
|
<FlexColumn shrink={false}>{activeNotifications}</FlexColumn>
|
|
</Fragment>
|
|
)}
|
|
{invalidatedNotifications.length > 0 && (
|
|
<Fragment>
|
|
<Heading>Past notifications</Heading>
|
|
<FlexColumn shrink={false}>{invalidatedNotifications}</FlexColumn>
|
|
</Fragment>
|
|
)}
|
|
{activeNotifications.length + invalidatedNotifications.length === 0 && (
|
|
<NoContent>
|
|
<Glyph
|
|
name="bell-null"
|
|
size={24}
|
|
variant="outline"
|
|
color={colors.light30}
|
|
/>
|
|
No Notifications
|
|
</NoContent>
|
|
)}
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
}
|
|
|
|
export const ConnectedNotificationsTable = connect<
|
|
StateFromProps,
|
|
DispatchFromProps,
|
|
OwnProps,
|
|
StoreState
|
|
>(
|
|
({
|
|
notifications: {
|
|
activeNotifications,
|
|
invalidatedNotifications,
|
|
blocklistedPlugins,
|
|
blocklistedCategories,
|
|
},
|
|
plugins: {devicePlugins, clientPlugins},
|
|
}) => ({
|
|
activeNotifications,
|
|
invalidatedNotifications,
|
|
blocklistedPlugins,
|
|
blocklistedCategories,
|
|
devicePlugins,
|
|
clientPlugins,
|
|
}),
|
|
{
|
|
updatePluginBlocklist,
|
|
updateCategoryBlocklist,
|
|
selectPlugin,
|
|
},
|
|
)(Searchable(NotificationsTable));
|
|
|
|
const shadow = (
|
|
props: {isSelected?: boolean; inactive?: boolean},
|
|
_hover?: boolean,
|
|
) => {
|
|
if (props.inactive) {
|
|
return `inset 0 0 0 1px ${colors.light10}`;
|
|
}
|
|
const shadow = ['1px 1px 5px rgba(0,0,0,0.1)'];
|
|
if (props.isSelected) {
|
|
shadow.push(`inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}`);
|
|
}
|
|
|
|
return shadow.join(',');
|
|
};
|
|
|
|
const SEVERITY_COLOR_MAP = {
|
|
warning: colors.yellow,
|
|
error: colors.red,
|
|
};
|
|
|
|
type NotificationBoxProps = {
|
|
inactive?: boolean;
|
|
isSelected?: boolean;
|
|
severity: keyof typeof SEVERITY_COLOR_MAP;
|
|
};
|
|
|
|
const NotificationBox = styled(FlexRow)<NotificationBoxProps>((props) => ({
|
|
backgroundColor: props.inactive ? 'transparent' : colors.white,
|
|
opacity: props.inactive ? 0.5 : 1,
|
|
alignItems: 'flex-start',
|
|
borderRadius: 5,
|
|
padding: 10,
|
|
flexShrink: 0,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
marginBottom: 10,
|
|
boxShadow: shadow(props),
|
|
'::before': {
|
|
content: '""',
|
|
display: !props.inactive && !props.isSelected ? 'block' : 'none',
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 3,
|
|
backgroundColor: SEVERITY_COLOR_MAP[props.severity] || colors.info,
|
|
},
|
|
':hover': {
|
|
boxShadow: shadow(props, true),
|
|
'& > *': {
|
|
opacity: 1,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const Title = styled.div({
|
|
minWidth: 150,
|
|
color: colors.light80,
|
|
flexShrink: 0,
|
|
marginBottom: 6,
|
|
fontWeight: 500,
|
|
lineHeight: 1,
|
|
fontSize: '1.1em',
|
|
});
|
|
|
|
const NotificationContent = styled(FlexColumn)<{isSelected?: boolean}>(
|
|
(props) => ({
|
|
marginLeft: 6,
|
|
marginRight: 10,
|
|
flexGrow: 1,
|
|
overflow: 'hidden',
|
|
maxHeight: props.isSelected ? 'none' : 56,
|
|
lineHeight: 1.4,
|
|
color: props.isSelected ? colors.light50 : colors.light30,
|
|
userSelect: 'text',
|
|
}),
|
|
);
|
|
|
|
const Actions = styled(FlexRow)({
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
color: colors.light20,
|
|
marginTop: 12,
|
|
borderTop: `1px solid ${colors.light05}`,
|
|
paddingTop: 8,
|
|
});
|
|
|
|
const NotificationButton = styled.div({
|
|
border: `1px solid ${colors.light20}`,
|
|
color: colors.light50,
|
|
borderRadius: 4,
|
|
textAlign: 'center',
|
|
padding: 4,
|
|
width: 80,
|
|
marginBottom: 4,
|
|
opacity: 0,
|
|
transition: '0.15s opacity',
|
|
'[data-role="notification"]:hover &': {
|
|
opacity: 0.5,
|
|
},
|
|
':last-child': {
|
|
marginBottom: 0,
|
|
},
|
|
'[data-role="notification"] &:hover': {
|
|
opacity: 1,
|
|
},
|
|
});
|
|
|
|
type ItemProps = {
|
|
onHighlight?: () => any;
|
|
onHidePlugin?: () => any;
|
|
onHideCategory?: () => any;
|
|
onClear?: () => any;
|
|
isSelected?: boolean;
|
|
inactive?: boolean;
|
|
selectPlugin?: (payload: {
|
|
selectedPlugin: string | null;
|
|
selectedApp: string | null;
|
|
deepLinkPayload: unknown;
|
|
}) => any;
|
|
logger?: Logger;
|
|
plugin: PluginDefinition | null | undefined;
|
|
};
|
|
|
|
type ItemState = {
|
|
reportedNotHelpful: boolean;
|
|
};
|
|
|
|
class NotificationItem extends Component<
|
|
ItemProps & PluginNotification,
|
|
ItemState
|
|
> {
|
|
constructor(props: ItemProps & PluginNotification) {
|
|
super(props);
|
|
const items: Array<Electron.MenuItemConstructorOptions> = [];
|
|
if (props.onHidePlugin && props.plugin) {
|
|
items.push({
|
|
label: `Hide ${getPluginTitle(props.plugin)} plugin`,
|
|
click: this.props.onHidePlugin,
|
|
});
|
|
}
|
|
if (props.onHideCategory) {
|
|
items.push({
|
|
label: 'Hide Similar',
|
|
click: this.props.onHideCategory,
|
|
});
|
|
}
|
|
items.push(
|
|
{label: 'Copy', role: 'copy'},
|
|
{label: 'Copy All', click: this.copy},
|
|
{label: 'Create Paste', click: this.createPaste},
|
|
);
|
|
|
|
this.contextMenuItems = items;
|
|
}
|
|
|
|
state = {reportedNotHelpful: false};
|
|
contextMenuItems: Array<Electron.MenuItemConstructorOptions>;
|
|
deepLinkButton = React.createRef();
|
|
|
|
createPaste = () => {
|
|
createPaste(this.getContent());
|
|
};
|
|
|
|
copy = () => getFlipperLib().writeTextToClipboard(this.getContent());
|
|
|
|
getContent = (): string =>
|
|
[
|
|
this.props.notification.timestamp,
|
|
`[${this.props.notification.severity}] ${this.props.notification.title}`,
|
|
this.props.notification.action,
|
|
this.props.notification.category,
|
|
textContent(this.props.notification.message),
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
|
|
openDeeplink = () => {
|
|
const {notification, pluginId, client} = this.props;
|
|
if (this.props.selectPlugin && notification.action) {
|
|
this.props.selectPlugin({
|
|
selectedPlugin: pluginId,
|
|
selectedApp: client,
|
|
deepLinkPayload: notification.action,
|
|
});
|
|
}
|
|
};
|
|
|
|
reportNotUseful = (e: React.MouseEvent<any>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (this.props.logger) {
|
|
this.props.logger.track(
|
|
'usage',
|
|
'notification-not-useful',
|
|
this.props.notification,
|
|
);
|
|
}
|
|
this.setState({reportedNotHelpful: true});
|
|
};
|
|
|
|
onHide = (e: React.MouseEvent<any>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (this.props.onHideCategory) {
|
|
this.props.onHideCategory();
|
|
} else if (this.props.onHidePlugin) {
|
|
this.props.onHidePlugin();
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const {
|
|
notification,
|
|
isSelected,
|
|
inactive,
|
|
onHidePlugin,
|
|
onHideCategory,
|
|
plugin,
|
|
} = this.props;
|
|
const {action} = notification;
|
|
|
|
return (
|
|
<ContextMenu<React.ComponentProps<typeof NotificationBox>>
|
|
data-role="notification"
|
|
component={NotificationBox}
|
|
severity={notification.severity}
|
|
onClick={this.props.onHighlight}
|
|
isSelected={isSelected}
|
|
inactive={inactive}
|
|
items={this.contextMenuItems}>
|
|
<Glyph name={(plugin ? plugin.icon : 'bell') || 'bell'} size={12} />
|
|
<NotificationContent isSelected={isSelected}>
|
|
<Title>{notification.title}</Title>
|
|
{notification.message}
|
|
{!inactive &&
|
|
isSelected &&
|
|
plugin &&
|
|
(action || onHidePlugin || onHideCategory) && (
|
|
<Actions>
|
|
<FlexRow>
|
|
{action && (
|
|
<Button onClick={this.openDeeplink}>
|
|
Open in {getPluginTitle(plugin)}
|
|
</Button>
|
|
)}
|
|
<ButtonGroup>
|
|
{onHideCategory && (
|
|
<Button onClick={onHideCategory}>Hide similar</Button>
|
|
)}
|
|
{onHidePlugin && (
|
|
<Button onClick={onHidePlugin}>
|
|
Hide {getPluginTitle(plugin)}
|
|
</Button>
|
|
)}
|
|
</ButtonGroup>
|
|
</FlexRow>
|
|
<span>
|
|
{notification.timestamp
|
|
? new Date(notification.timestamp).toTimeString()
|
|
: ''}
|
|
</span>
|
|
</Actions>
|
|
)}
|
|
</NotificationContent>
|
|
{action && !inactive && !isSelected && (
|
|
<FlexColumn style={{alignSelf: 'center'}}>
|
|
{action && (
|
|
<NotificationButton onClick={this.openDeeplink}>
|
|
Open
|
|
</NotificationButton>
|
|
)}
|
|
{this.state.reportedNotHelpful ? (
|
|
<NotificationButton onClick={this.onHide}>
|
|
Hide
|
|
</NotificationButton>
|
|
) : (
|
|
<NotificationButton onClick={this.reportNotUseful}>
|
|
Not helpful
|
|
</NotificationButton>
|
|
)}
|
|
</FlexColumn>
|
|
)}
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
}
|