diff --git a/src/components/Header/Tools.tsx b/src/components/Header/Tools.tsx index 93d5b460c..5c0d70753 100644 --- a/src/components/Header/Tools.tsx +++ b/src/components/Header/Tools.tsx @@ -82,7 +82,7 @@ const Tools = () => { }); const { xs } = useWindowWidth(); const user = useSelector(({ chrome: { user } }: ReduxState) => user!); - const unreadNotifications = useSelector(({ chrome: { notifications } }: ReduxState) => notifications?.data?.filter((isRead) => isRead) || []); + const unreadNotifications = useSelector(({ chrome: { notifications } }: ReduxState) => notifications?.data?.filter((isRead) => !isRead) || []); const isDrawerExpanded = useSelector(({ chrome: { notifications } }: ReduxState) => notifications?.isExpanded); const dispatch = useDispatch(); const libjwt = useContext(LibtJWTContext); diff --git a/src/components/NotificationsDrawer/DrawerPanelContent.tsx b/src/components/NotificationsDrawer/DrawerPanelContent.tsx index a0f80d281..bdf629598 100644 --- a/src/components/NotificationsDrawer/DrawerPanelContent.tsx +++ b/src/components/NotificationsDrawer/DrawerPanelContent.tsx @@ -1,16 +1,33 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { PopoverPosition } from '@patternfly/react-core/dist/dynamic/components/Popover'; +import { Icon } from '@patternfly/react-core/dist/dynamic/components/Icon'; +import { Badge } from '@patternfly/react-core/dist/dynamic/components/Badge'; +import { Flex, FlexItem } from '@patternfly/react-core/dist/dynamic/layouts/Flex'; +import { Dropdown, DropdownGroup, DropdownItem, DropdownList } from '@patternfly/react-core/dist/dynamic/components/Dropdown'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core/dist/dynamic/components/MenuToggle'; +import { Divider } from '@patternfly/react-core/dist/dynamic/components/Divider'; import { Button } from '@patternfly/react-core/dist/dynamic/components/Button'; import { EmptyState, EmptyStateBody, EmptyStateIcon } from '@patternfly/react-core/dist/dynamic/components/EmptyState'; -import { NotificationDrawer, NotificationDrawerHeader } from '@patternfly/react-core/dist/dynamic/components/NotificationDrawer'; +import { + NotificationDrawer, + NotificationDrawerBody, + NotificationDrawerHeader, + NotificationDrawerList, +} from '@patternfly/react-core/dist/dynamic/components/NotificationDrawer'; import { Text } from '@patternfly/react-core/dist/dynamic/components/Text'; import { Title } from '@patternfly/react-core/dist/dynamic/components/Title'; import { useDispatch, useSelector } from 'react-redux'; -import { toggleNotificationsDrawer } from '../../redux/actions'; import FilterIcon from '@patternfly/react-icons/dist/dynamic/icons/filter-icon'; -import EllipsisVIcon from '@patternfly/react-icons/dist/dynamic/icons/ellipsis-v-icon'; import BellSlashIcon from '@patternfly/react-icons/dist/dynamic/icons/bell-slash-icon'; import ExternalLinkSquareAltIcon from '@patternfly/react-icons/dist/dynamic/icons/external-link-square-alt-icon'; -import { ReduxState } from '../../redux/store'; +import ExternalLinkAltIcon from '@patternfly/react-icons/dist/dynamic/icons/external-link-alt-icon'; +import EllipsisVIcon from '@patternfly/react-icons/dist/dynamic/icons/ellipsis-v-icon'; +import orderBy from 'lodash/orderBy'; +import { useNavigate } from 'react-router-dom'; +import { NotificationData, ReduxState } from '../../redux/store'; +import NotificationItem from './NotificationItem'; +import { markAllNotificationsAsRead, markAllNotificationsAsUnread, toggleNotificationsDrawer } from '../../redux/actions'; +import { filterConfig } from './notificationDrawerUtils'; export type DrawerPanelProps = { innerRef: React.Ref; @@ -39,19 +56,178 @@ const EmptyNotifications = () => ( ); const DrawerPanelBase = ({ innerRef }: DrawerPanelProps) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); + const [activeFilters, setActiveFilters] = useState([]); + const [filteredNotifications, setFilteredNotifications] = useState([]); + const navigate = useNavigate(); const dispatch = useDispatch(); const notifications = useSelector(({ chrome: { notifications } }: ReduxState) => notifications?.data || []); + const isOrgAdmin = useSelector(({ chrome }: ReduxState) => chrome.user?.identity.user?.is_org_admin); + + useEffect(() => { + const modifiedNotifications = (activeFilters || []).reduce( + (acc: NotificationData[], chosenFilter: string) => [...acc, ...notifications.filter(({ source }) => source === chosenFilter)], + [] + ); + + setFilteredNotifications(modifiedNotifications); + }, [activeFilters]); + + const onNotificationsDrawerClose = () => { + setActiveFilters([]); + dispatch(toggleNotificationsDrawer()); + }; + + const onMarkAllAsRead = () => { + dispatch(markAllNotificationsAsRead()); + setIsDropdownOpen(false); + }; + + const onMarkAllAsUnread = () => { + dispatch(markAllNotificationsAsUnread()); + setIsDropdownOpen(false); + }; + + const onFilterSelect = (chosenFilter: string) => { + activeFilters.includes(chosenFilter) + ? setActiveFilters(activeFilters.filter((filter) => filter !== chosenFilter)) + : setActiveFilters([...activeFilters, chosenFilter]); + }; + + const onNavigateTo = (link: string) => { + navigate(link); + onNotificationsDrawerClose(); + }; + + const dropdownItems = [ + + Mark visible as read + , + + Mark visible as unread + , + , + onNavigateTo('/settings/notifications/eventlog')}> + + View event log + + + + + + + , + isOrgAdmin && ( + onNavigateTo('/settings/notifications/configure-events')}> + + Configure notification settings + + + + + + + + ), + onNavigateTo('/settings/notifications/user-preferences')}> + + Manage my notification preferences + + + + + + + , + ]; + + const filterDropdownItems = () => { + return [ + + + {filterConfig.map((source) => ( + onFilterSelect(source.value)} + isDisabled={notifications.length === 0} + isSelected={activeFilters.includes(source.value)} + hasCheckbox + > + {source.title} + + ))} + + setActiveFilters([])}> + Reset filters + + + , + ]; + }; + + const renderNotifications = () => { + if (notifications.length === 0) { + return ; + } + + const sortedNotifications = orderBy(activeFilters?.length > 0 ? filteredNotifications : notifications, ['read', 'created'], ['asc', 'asc']); + + return sortedNotifications.map((notification) => ( + + )); + }; + return ( - dispatch(toggleNotificationsDrawer())}> - - + + {activeFilters.length > 0 && {activeFilters.length}} + ) => ( + setIsFilterDropdownOpen(!isFilterDropdownOpen)} + id="notifications-filter-toggle" + variant="plain" + > + + + )} + isOpen={isFilterDropdownOpen} + onOpenChange={setIsFilterDropdownOpen} + popperProps={{ + position: PopoverPosition.right, + }} + id="notifications-filter-dropdown" + aria-label="Notifications filter" + > + {filterDropdownItems()} + + ) => ( + setIsDropdownOpen(!isDropdownOpen)} + variant="plain" + id="notifications-actions-toggle" + isFullWidth + > + + + )} + isOpen={isDropdownOpen} + onOpenChange={setIsDropdownOpen} + popperProps={{ + position: PopoverPosition.right, + }} + id="notifications-actions-dropdown" + > + {dropdownItems} + - {notifications.length === 0 ? : 'TODO'} + + {renderNotifications()} + ); }; diff --git a/src/components/NotificationsDrawer/NotificationItem.tsx b/src/components/NotificationsDrawer/NotificationItem.tsx new file mode 100644 index 000000000..dabf6fbd4 --- /dev/null +++ b/src/components/NotificationsDrawer/NotificationItem.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { + NotificationDrawerList, + NotificationDrawerListItem, + NotificationDrawerListItemBody, + NotificationDrawerListItemHeader, +} from '@patternfly/react-core/dist/dynamic/components/NotificationDrawer'; +import { PopoverPosition } from '@patternfly/react-core/dist/dynamic/components/Popover'; +import { Checkbox } from '@patternfly/react-core/dist/dynamic/components/Checkbox'; +import { Label } from '@patternfly/react-core/dist/dynamic/components/Label'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core/dist/dynamic/components/MenuToggle'; +import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/dist/dynamic/components/Dropdown'; +import EllipsisVIcon from '@patternfly/react-icons/dist/dynamic/icons/ellipsis-v-icon'; +import DateFormat from '@redhat-cloud-services/frontend-components/DateFormat'; +import { useDispatch } from 'react-redux'; +import { NotificationData } from '../../redux/store'; +import { markNotificationAsRead, markNotificationAsUnread } from '../../redux/actions'; + +interface NotificationItemProps { + notification: NotificationData; + onNavigateTo: (link: string) => void; +} +const NotificationItem: React.FC = ({ notification, onNavigateTo }) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dispatch = useDispatch(); + + const onCheckboxToggle = () => { + dispatch(!notification.read ? markNotificationAsRead(notification.id) : markNotificationAsUnread(notification.id)); + setIsDropdownOpen(false); + }; + + const notificationDropdownItems = [ + {`Mark as ${!notification.read ? 'read' : 'unread'}`}, + onNavigateTo('settings/notifications/configure-events')}> + Manage this event + , + ]; + return ( + + + + + + ) => ( + setIsDropdownOpen(!isDropdownOpen)} + id="notification-item-toggle" + isExpanded={isDropdownOpen} + variant="plain" + > + + + )} + isOpen={isDropdownOpen} + onOpenChange={setIsDropdownOpen} + popperProps={{ + position: PopoverPosition.right, + }} + id="notification-item-dropdown" + > + {notificationDropdownItems} + + + }> + + {notification.description} + + + + + ); +}; + +export default NotificationItem; diff --git a/src/components/NotificationsDrawer/notificationDrawerUtils.ts b/src/components/NotificationsDrawer/notificationDrawerUtils.ts new file mode 100644 index 000000000..cb7279e35 --- /dev/null +++ b/src/components/NotificationsDrawer/notificationDrawerUtils.ts @@ -0,0 +1,6 @@ +export const filterConfig = [ + { title: 'Console', value: 'console' }, + { title: 'OpenShift', value: 'openshift' }, + { title: 'Red Hat Enterprise Linux', value: 'rhel' }, + { title: 'Ansible Automation Platform', value: 'ansible' }, +]; diff --git a/src/redux/action-types.ts b/src/redux/action-types.ts index 9dcec285d..1e2dfee27 100644 --- a/src/redux/action-types.ts +++ b/src/redux/action-types.ts @@ -41,3 +41,8 @@ export const CLEAR_QUICKSTARTS = '@@chrome/clear-quickstarts'; export const SET_GATEWAY_ERROR = '@@chrome/set-gateway-error'; export const TOGGLE_NOTIFICATIONS_DRAWER = '@@chrome/toggle-notifications-drawer'; + +export const MARK_NOTIFICATION_AS_READ = '@@chrome/mark-notification-as-read'; +export const MARK_NOTIFICATION_AS_UNREAD = '@@chrome/mark-notification-as-unread'; +export const MARK_ALL_NOTIFICATION_AS_READ = '@@chrome/mark-all-notification-as-read'; +export const MARK_ALL_NOTIFICATION_AS_UNREAD = '@@chrome/mark-all-notification-as-unread'; diff --git a/src/redux/actions.ts b/src/redux/actions.ts index dea54bb16..fb46f0101 100644 --- a/src/redux/actions.ts +++ b/src/redux/actions.ts @@ -206,3 +206,21 @@ export const setGatewayError = (error?: ThreeScaleError) => ({ export const toggleNotificationsDrawer = () => ({ type: actionTypes.TOGGLE_NOTIFICATIONS_DRAWER, }); + +export const markNotificationAsRead = (id: number) => ({ + type: actionTypes.MARK_NOTIFICATION_AS_READ, + payload: id, +}); + +export const markNotificationAsUnread = (id: number) => ({ + type: actionTypes.MARK_NOTIFICATION_AS_UNREAD, + payload: id, +}); + +export const markAllNotificationsAsRead = () => ({ + type: actionTypes.MARK_ALL_NOTIFICATION_AS_READ, +}); + +export const markAllNotificationsAsUnread = () => ({ + type: actionTypes.MARK_ALL_NOTIFICATION_AS_UNREAD, +}); diff --git a/src/redux/chromeReducers.ts b/src/redux/chromeReducers.ts index cc3d3c8ea..83d149abf 100644 --- a/src/redux/chromeReducers.ts +++ b/src/redux/chromeReducers.ts @@ -4,7 +4,7 @@ import { REQUESTS_COUNT, REQUESTS_DATA } from '../utils/consts'; import { ChromeModule, NavItem, Navigation } from '../@types/types'; import { ITLess, generateRoutesList, highlightItems, isBeta, levelArray } from '../utils/common'; import { ThreeScaleError } from '../utils/responseInterceptors'; -import { AccessRequest, ChromeState } from './store'; +import { AccessRequest, ChromeState, NotificationData } from './store'; export function contextSwitcherBannerReducer(state: ChromeState): ChromeState { return { @@ -338,7 +338,56 @@ export function toggleNotificationsReducer(state: ChromeState) { ...state, notifications: { ...state.notifications, + data: state.notifications?.data || [], isExpanded: !state.notifications?.isExpanded, }, }; } + +export function markNotificationAsRead(state: ChromeState, { payload }: { payload: number }): ChromeState { + return { + ...state, + notifications: { + isExpanded: state.notifications?.isExpanded || false, + count: state.notifications?.data?.length || 0, + data: (state.notifications?.data || []).map((notification: NotificationData) => + notification.id === payload ? { ...notification, read: true } : notification + ), + }, + }; +} + +export function markNotificationAsUnread(state: ChromeState, { payload }: { payload: number }): ChromeState { + return { + ...state, + notifications: { + isExpanded: state.notifications?.isExpanded || false, + count: state.notifications?.data?.length || 0, + data: (state.notifications?.data || []).map((notification: NotificationData) => + notification.id === payload ? { ...notification, read: false } : notification + ), + }, + }; +} + +export function markAllNotificationsAsRead(state: ChromeState): ChromeState { + return { + ...state, + notifications: { + isExpanded: state.notifications?.isExpanded || false, + count: state.notifications?.count || 0, + data: (state.notifications?.data || []).map((notification) => ({ ...notification, read: true })), + }, + }; +} + +export function markAllNotificationsAsUnread(state: ChromeState): ChromeState { + return { + ...state, + notifications: { + isExpanded: state.notifications?.isExpanded || false, + count: state.notifications?.data?.length || 0, + data: (state.notifications?.data || []).map((notification) => ({ ...notification, read: false })), + }, + }; +} diff --git a/src/redux/index.ts b/src/redux/index.ts index d3c058adc..8b745ba48 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -15,6 +15,10 @@ import { loginReducer, markAccessRequestRequestReducer, markActiveProduct, + markAllNotificationsAsRead, + markAllNotificationsAsUnread, + markNotificationAsRead, + markNotificationAsUnread, onPageAction, onPageObjectId, onRegisterModule, @@ -58,6 +62,10 @@ import { LOAD_MODULES_SCHEMA, LOAD_NAVIGATION_LANDING_PAGE, MARK_ACTIVE_PRODUCT, + MARK_ALL_NOTIFICATION_AS_READ, + MARK_ALL_NOTIFICATION_AS_UNREAD, + MARK_NOTIFICATION_AS_READ, + MARK_NOTIFICATION_AS_UNREAD, MARK_REQUEST_NOTIFICATION_SEEN, POPULATE_QUICKSTARTS_CATALOG, REGISTER_MODULE, @@ -100,6 +108,10 @@ const reducers = { [SET_GATEWAY_ERROR]: setGatewayError, [CLEAR_QUICKSTARTS]: clearQuickstartsReducer, [TOGGLE_NOTIFICATIONS_DRAWER]: toggleNotificationsReducer, + [MARK_NOTIFICATION_AS_READ]: markNotificationAsRead, + [MARK_NOTIFICATION_AS_UNREAD]: markNotificationAsUnread, + [MARK_ALL_NOTIFICATION_AS_READ]: markAllNotificationsAsRead, + [MARK_ALL_NOTIFICATION_AS_UNREAD]: markAllNotificationsAsUnread, }; const globalFilter = { @@ -128,6 +140,11 @@ export const chromeInitialState: ReduxState = { quickstarts: {}, }, moduleRoutes: [], + notifications: { + data: [], + isExpanded: false, + count: 0, + }, }, globalFilter: globalFilterDefaultState, }; diff --git a/src/redux/store.d.ts b/src/redux/store.d.ts index be4b74732..a72375108 100644 --- a/src/redux/store.d.ts +++ b/src/redux/store.d.ts @@ -11,11 +11,18 @@ export type InternalNavigation = { export type AccessRequest = { request_id: string; created: string; seen: boolean }; +export type NotificationData = { + id: number; + title: string; + description: string; + read: boolean; + source: string; + created: string; +}; + export type Notifications = { isExpanded: boolean; - data: Array<{ - isRead: boolean; - }>; + data: NotificationData[]; count: number; };