From f68cef3046f74d62148e40253eed47d1489b5383 Mon Sep 17 00:00:00 2001 From: Chaiwat Ekkaewnumchai Date: Mon, 30 Nov 2020 02:11:10 -0800 Subject: [PATCH] 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 --- desktop/app/src/sandy-chrome/LeftRail.tsx | 13 +- .../notification/Notification.tsx | 135 ++++++++++++------ .../__tests__/notificationUtils.spec.tsx | 111 ++++++++++++++ .../notification/notificationUtils.tsx | 39 +++++ 4 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 desktop/app/src/sandy-chrome/notification/__tests__/notificationUtils.spec.tsx create mode 100644 desktop/app/src/sandy-chrome/notification/notificationUtils.tsx diff --git a/desktop/app/src/sandy-chrome/LeftRail.tsx b/desktop/app/src/sandy-chrome/LeftRail.tsx index 4d350f9f4..d8b808832 100644 --- a/desktop/app/src/sandy-chrome/LeftRail.tsx +++ b/desktop/app/src/sandy-chrome/LeftRail.tsx @@ -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 ( } title="Notifications" selected={toplevelSelection === 'notification'} - count={notificationCount} + count={activeNotifications.length} onClick={() => setToplevelSelection('notification')} /> ); diff --git a/desktop/app/src/sandy-chrome/notification/Notification.tsx b/desktop/app/src/sandy-chrome/notification/Notification.tsx index 6dda2f2d3..91e0228c0 100644 --- a/desktop/app/src/sandy-chrome/notification/Notification.tsx +++ b/desktop/app/src/sandy-chrome/notification/Notification.tsx @@ -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( + () => ( + + + {onHideSimilar && ( + + Hide Similar + + )} + + Hide {pluginName} + + + }> + - + ); } @@ -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 = 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 = ( diff --git a/desktop/app/src/sandy-chrome/notification/__tests__/notificationUtils.spec.tsx b/desktop/app/src/sandy-chrome/notification/__tests__/notificationUtils.spec.tsx new file mode 100644 index 000000000..fcdf8cc8c --- /dev/null +++ b/desktop/app/src/sandy-chrome/notification/__tests__/notificationUtils.spec.tsx @@ -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 = [...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); +}); diff --git a/desktop/app/src/sandy-chrome/notification/notificationUtils.tsx b/desktop/app/src/sandy-chrome/notification/notificationUtils.tsx new file mode 100644 index 000000000..6f790b91e --- /dev/null +++ b/desktop/app/src/sandy-chrome/notification/notificationUtils.tsx @@ -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, + blocklistedPlugins?: Array, + blocklistedCategories?: Array, + searchString?: string, +): Array { + 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, + ); +}