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:
committed by
Facebook GitHub Bot
parent
04de290b27
commit
f68cef3046
@@ -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')}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<Layout.Horizontal gap="tiny" center>
|
||||
{icon}
|
||||
<Text style={{fontSize: theme.fontSize.smallBody}}>{pluginName}</Text>
|
||||
</Layout.Horizontal>
|
||||
<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,44 +213,51 @@ 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) => {
|
||||
const plugin = getPlugin(noti.pluginId);
|
||||
const client = getClientQuery(noti.client);
|
||||
return {
|
||||
...noti.notification,
|
||||
onOpen: () =>
|
||||
dispatch(
|
||||
selectPlugin({
|
||||
selectedPlugin: noti.pluginId,
|
||||
selectedApp: noti.client,
|
||||
deepLinkPayload: noti.notification.action,
|
||||
}),
|
||||
),
|
||||
clientName: client?.device_id,
|
||||
appName: client?.app,
|
||||
pluginName: plugin?.title ?? noti.pluginId,
|
||||
iconName: plugin?.icon,
|
||||
};
|
||||
}),
|
||||
[activeNotifications, getPlugin, getClientQuery, searchString, dispatch],
|
||||
activeNotifications.map((noti) => {
|
||||
const plugin = getPlugin(noti.pluginId);
|
||||
const client = getClientQuery(noti.client);
|
||||
return {
|
||||
...noti.notification,
|
||||
onOpen: () =>
|
||||
dispatch(
|
||||
selectPlugin({
|
||||
selectedPlugin: noti.pluginId,
|
||||
selectedApp: noti.client,
|
||||
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, notifications, getPlugin, getClientQuery, dispatch],
|
||||
);
|
||||
|
||||
const actions = (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user