Add Extra Action for Each Notification

Summary: Previously, notifications can be hidden by category or plugin. This diff ports that functionality to Sandy

Reviewed By: mweststrate

Differential Revision: D25021697

fbshipit-source-id: 28bdd1c169fcef4db79c2452db8cbb5f0bce6312
This commit is contained in:
Chaiwat Ekkaewnumchai
2020-11-30 02:11:10 -08:00
committed by Facebook GitHub Bot
parent 04de290b27
commit f68cef3046
4 changed files with 251 additions and 47 deletions

View File

@@ -54,6 +54,8 @@ import {getInstance} from '../fb-stubs/Logger';
import {isStaticViewActive} from '../chrome/mainsidebar/sidebarUtils';
import {getUser} from '../fb-stubs/user';
import {SandyRatingButton} from '../chrome/RatingButton';
import {filterNotifications} from './notification/notificationUtils';
import {useMemoize} from '../utils/useMemoize';
const LeftRailButtonElem = styled(Button)<{kind?: 'small'}>(({kind}) => ({
width: kind === 'small' ? 32 : 36,
@@ -213,15 +215,18 @@ function NotificationButton({
toplevelSelection,
setToplevelSelection,
}: ToplevelProps) {
const notificationCount = useStore(
(state) => state.notifications.activeNotifications.length,
);
const notifications = useStore((state) => state.notifications);
const activeNotifications = useMemoize(filterNotifications, [
notifications.activeNotifications,
notifications.blacklistedPlugins,
notifications.blacklistedCategories,
]);
return (
<LeftRailButton
icon={<BellOutlined />}
title="Notifications"
selected={toplevelSelection === 'notification'}
count={notificationCount}
count={activeNotifications.length}
onClick={() => setToplevelSelection('notification')}
/>
);

View File

@@ -10,7 +10,7 @@
import React, {useCallback, useMemo, useState} from 'react';
import {Layout, theme} from 'flipper-plugin';
import {styled, Glyph} from '../../ui';
import {Input, Typography, Button, Collapse} from 'antd';
import {Input, Typography, Button, Collapse, Dropdown, Menu} from 'antd';
import {
DownOutlined,
UpOutlined,
@@ -18,6 +18,7 @@ import {
ExclamationCircleOutlined,
SettingOutlined,
DeleteOutlined,
EllipsisOutlined,
} from '@ant-design/icons';
import {LeftSidebar, SidebarTitle} from '../LeftSidebar';
import {Notification as NotificationData} from '../../plugin';
@@ -25,10 +26,18 @@ import {useStore, useDispatch} from '../../utils/useStore';
import {ClientQuery} from '../../Client';
import {deconstructClientId} from '../../utils/clientUtils';
import {selectPlugin} from '../../reducers/connections';
import {clearAllNotifications} from '../../reducers/notifications';
import {
clearAllNotifications,
updateCategoryBlacklist,
updatePluginBlacklist,
} from '../../reducers/notifications';
import {filterNotifications} from './notificationUtils';
import {useMemoize} from '../../utils/useMemoize';
type NotificationExtra = {
onOpen: () => void;
onHideSimilar: (() => void) | null;
onHidePlugin: () => void;
clientName: string | undefined;
appName: string | undefined;
pluginName: string;
@@ -49,6 +58,11 @@ const CollapseContainer = styled.div({
},
});
const ItemContainer = styled(Layout.Container)({
'.notification-item-action': {visibility: 'hidden'},
':hover': {'.notification-item-action': {visibility: 'visible'}},
});
function DetailCollapse({detail}: {detail: string | React.ReactNode}) {
const detailView =
typeof detail === 'string' ? (
@@ -92,6 +106,8 @@ function DetailCollapse({detail}: {detail: string | React.ReactNode}) {
function NotificationEntry({notification}: {notification: PluginNotification}) {
const {
onOpen,
onHideSimilar,
onHidePlugin,
message,
title,
clientName,
@@ -100,17 +116,43 @@ function NotificationEntry({notification}: {notification: PluginNotification}) {
iconName,
} = notification;
const actions = useMemo(
() => (
<Layout.Horizontal className="notification-item-action">
<Dropdown
overlay={
<Menu>
{onHideSimilar && (
<Menu.Item key="hide_similar" onClick={onHideSimilar}>
Hide Similar
</Menu.Item>
)}
<Menu.Item key="hide_plugin" onClick={onHidePlugin}>
Hide {pluginName}
</Menu.Item>
</Menu>
}>
<Button type="text" size="small" icon={<EllipsisOutlined />} />
</Dropdown>
</Layout.Horizontal>
),
[onHideSimilar, onHidePlugin, pluginName],
);
const icon = iconName ? (
<Glyph name={iconName} size={16} color={theme.primaryColor} />
) : (
<ExclamationCircleOutlined style={{color: theme.primaryColor}} />
);
return (
<Layout.Container gap="small" pad="medium">
<ItemContainer gap="small" pad="medium">
<Layout.Right center>
<Layout.Horizontal gap="tiny" center>
{icon}
<Text style={{fontSize: theme.fontSize.smallBody}}>{pluginName}</Text>
</Layout.Horizontal>
{actions}
</Layout.Right>
<Title level={4} ellipsis={{rows: 2}}>
{title}
</Title>
@@ -123,7 +165,7 @@ function NotificationEntry({notification}: {notification: PluginNotification}) {
Open {pluginName}
</Button>
<DetailCollapse detail={message} />
</Layout.Container>
</ItemContainer>
);
}
@@ -171,25 +213,16 @@ export function Notification() {
[clientPlugins, devicePlugins],
);
const activeNotifications = useStore(
(state) => state.notifications.activeNotifications,
);
const notifications = useStore((state) => state.notifications);
const activeNotifications = useMemoize(filterNotifications, [
notifications.activeNotifications,
notifications.blacklistedPlugins,
notifications.blacklistedCategories,
]);
const displayedNotifications: Array<PluginNotification> = useMemo(
() =>
activeNotifications
.filter(
(noti) =>
noti.notification.title
.toLocaleLowerCase()
.includes(searchString.toLocaleLowerCase()) ||
(typeof noti.notification.message === 'string'
? noti.notification.message
.toLocaleLowerCase()
.includes(searchString.toLocaleLowerCase())
: false),
)
.map((noti) => {
activeNotifications.map((noti) => {
const plugin = getPlugin(noti.pluginId);
const client = getClientQuery(noti.client);
return {
@@ -202,13 +235,29 @@ export function Notification() {
deepLinkPayload: noti.notification.action,
}),
),
onHideSimilar: noti.notification.category
? () =>
dispatch(
updateCategoryBlacklist([
...notifications.blacklistedCategories,
noti.notification.category!,
]),
)
: null,
onHidePlugin: () =>
dispatch(
updatePluginBlacklist([
...notifications.blacklistedPlugins,
noti.pluginId,
]),
),
clientName: client?.device_id,
appName: client?.app,
pluginName: plugin?.title ?? noti.pluginId,
iconName: plugin?.icon,
};
}),
[activeNotifications, getPlugin, getClientQuery, searchString, dispatch],
[activeNotifications, notifications, getPlugin, getClientQuery, dispatch],
);
const actions = (

View File

@@ -0,0 +1,111 @@
/**
* 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 {PluginNotification} from '../../../reducers/notifications';
import {filterNotifications} from '../notificationUtils';
const PLUGIN_COUNT = 3;
const CLIENT_COUNT = 2;
const CATEGORY_LABEL_COUNT = 2 * PLUGIN_COUNT * CLIENT_COUNT;
const CATEGORY_LABEL = 'some category';
const unfilteredNotifications: Array<PluginNotification> = [...Array(20)].map(
(_, idx) => ({
notification: {
id: `${idx}`,
title: `title ${idx}`,
message: `message ${idx}`,
severity: 'warning',
category: idx % CATEGORY_LABEL_COUNT ? undefined : CATEGORY_LABEL,
},
pluginId: `plugin_${idx % PLUGIN_COUNT}`,
client: `client_${idx % CLIENT_COUNT}`,
}),
);
test('Filter nothing', async () => {
const filteredNotifications = filterNotifications(
unfilteredNotifications.slice(),
);
expect(filteredNotifications.length).toBe(unfilteredNotifications.length);
expect(filteredNotifications).toEqual(unfilteredNotifications);
});
test('Filter by single pluginId', async () => {
const blockedPluginId = 'plugin_0';
const filteredNotifications = filterNotifications(
unfilteredNotifications.slice(),
[blockedPluginId],
);
const expectedNotification = unfilteredNotifications
.slice()
.filter((_, idx) => idx % PLUGIN_COUNT);
expect(filteredNotifications.length).toBe(expectedNotification.length);
expect(filteredNotifications).toEqual(expectedNotification);
});
test('Filter by multiple pluginId', async () => {
const blockedPluginIds = ['plugin_1', 'plugin_2'];
const filteredNotifications = filterNotifications(
unfilteredNotifications.slice(),
blockedPluginIds,
);
const expectedNotification = unfilteredNotifications
.slice()
.filter((_, idx) => !(idx % PLUGIN_COUNT));
expect(filteredNotifications.length).toBe(expectedNotification.length);
expect(filteredNotifications).toEqual(expectedNotification);
});
test('Filter by category', async () => {
const blockedCategory = CATEGORY_LABEL;
const filteredNotifications = filterNotifications(
unfilteredNotifications.slice(),
[],
[blockedCategory],
);
const expectedNotification = unfilteredNotifications
.slice()
.filter((_, idx) => idx % CATEGORY_LABEL_COUNT);
expect(filteredNotifications.length).toBe(expectedNotification.length);
expect(filteredNotifications).toEqual(expectedNotification);
});
test('Filter by pluginId and category', async () => {
const blockedCategory = CATEGORY_LABEL;
const blockedPluginId = 'plugin_1';
const filteredNotifications = filterNotifications(
unfilteredNotifications.slice(),
[blockedPluginId],
[blockedCategory],
);
const expectedNotification = unfilteredNotifications
.slice()
.filter((_, idx) => idx % CATEGORY_LABEL_COUNT && idx % PLUGIN_COUNT !== 1);
expect(filteredNotifications.length).toBe(expectedNotification.length);
expect(filteredNotifications).toEqual(expectedNotification);
});
test('Filter by string searching', async () => {
const searchString = 'age 5';
const filteredNotifications = filterNotifications(
unfilteredNotifications.slice(),
[],
[],
searchString,
);
const expectedNotification = [unfilteredNotifications[5]];
expect(filteredNotifications.length).toBe(expectedNotification.length);
expect(filteredNotifications).toEqual(expectedNotification);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {PluginNotification} from '../../reducers/notifications';
export function filterNotifications(
notifications: Array<PluginNotification>,
blocklistedPlugins?: Array<string>,
blocklistedCategories?: Array<string>,
searchString?: string,
): Array<PluginNotification> {
return notifications
.filter((noti) =>
blocklistedPlugins ? !blocklistedPlugins.includes(noti.pluginId) : true,
)
.filter((noti) =>
blocklistedCategories && noti.notification.category
? !blocklistedCategories?.includes(noti.notification.category)
: true,
)
.filter((noti) =>
searchString
? noti.notification.title
.toLocaleLowerCase()
.includes(searchString.toLocaleLowerCase()) ||
(typeof noti.notification.message === 'string'
? noti.notification.message
.toLocaleLowerCase()
.includes(searchString.toLocaleLowerCase())
: false)
: true,
);
}