Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notification Drawer Update #2571

Merged
merged 21 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5b08115
First pass for notification drawers, dropdowns and filters.
arivepr Jul 14, 2023
c4caa32
Fixed issue with notifications items sharing state, and repeating fil…
arivepr Jul 17, 2023
04fd7ee
Connected redux management for notifications.
arivepr Jul 26, 2023
6c900fd
Adding source filtering functionality.
arivepr Jul 26, 2023
a0e0e01
Refactoring reducers/actions and other parts of code.
arivepr Aug 9, 2023
aa4c288
Switching to dispatchion action creator
arivepr Aug 16, 2023
dda00c8
Fixed marking as read issue.
arivepr Aug 17, 2023
e3bfecf
Refactoring of notification filter menu and functionality. Matching m…
arivepr Aug 18, 2023
a4b8c21
Fixing PF5 related issues after rebasing
arivepr Aug 22, 2023
e140689
Switching to lodash sorting, fixed visual bug
arivepr Aug 29, 2023
354f354
Switching to FEC
arivepr Aug 30, 2023
f30a632
Fixing dropdowns, refactoring code, switching to dynamic imports
arivepr Sep 1, 2023
bcaa731
Further code refactoring, and adding navigation links.
arivepr Sep 2, 2023
9362cd6
Changing data loading, adding dropdown options and refactoring
arivepr Sep 4, 2023
58dcec0
Updating utils after refactoring.
arivepr Sep 5, 2023
d40694e
Closing drawer on navigation, and fixed small filtering bug.
arivepr Sep 5, 2023
7937934
Adding notifications initial state to redux.
arivepr Sep 5, 2023
41f50d5
Adding dropdown wrapper and adjusting bell icon logic.
arivepr Sep 6, 2023
351ba96
Readjusting code line.
arivepr Sep 6, 2023
e9d3834
Eliminating console errors.
arivepr Sep 6, 2023
668c93f
Merge branch 'master' into notification-drawer-items
Hyperkid123 Sep 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Header/Tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
200 changes: 187 additions & 13 deletions src/components/NotificationsDrawer/DrawerPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
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 { Checkbox } from '@patternfly/react-core/dist/dynamic/components/Checkbox';
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<unknown>;
Expand Down Expand Up @@ -39,19 +57,175 @@ const EmptyNotifications = () => (
);

const DrawerPanelBase = ({ innerRef }: DrawerPanelProps) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [filteredNotifications, setFilteredNotifications] = useState<NotificationData[]>([]);
arivepr marked this conversation as resolved.
Show resolved Hide resolved
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]);
};

arivepr marked this conversation as resolved.
Show resolved Hide resolved
const onNavigateTo = (link: string) => {
navigate(link);
onNotificationsDrawerClose();
};

const dropdownItems = [
<DropdownItem key="read all" onClick={onMarkAllAsRead} isDisabled={notifications.length === 0}>
Mark visible as read
</DropdownItem>,
<DropdownItem key="unread all" onClick={onMarkAllAsUnread} isDisabled={notifications.length === 0}>
Mark visible as unread
</DropdownItem>,
<Divider key="divider" />,
<DropdownItem key="event log" onClick={() => onNavigateTo('/settings/notifications/eventlog')}>
<Flex>
<FlexItem>View event log</FlexItem>
<FlexItem align={{ default: 'alignRight' }}>
<Icon className="pf-v5-u-ml-auto">
<ExternalLinkAltIcon />
</Icon>
</FlexItem>
</Flex>
</DropdownItem>,
arivepr marked this conversation as resolved.
Show resolved Hide resolved
isOrgAdmin && (
<DropdownItem key="notification settings" onClick={() => onNavigateTo('/settings/notifications/configure-events')}>
<Flex>
<FlexItem>Configure notification settings</FlexItem>
<FlexItem align={{ default: 'alignRight' }}>
<Icon className="pf-v5-u-ml-auto">
<ExternalLinkAltIcon />
</Icon>
</FlexItem>
</Flex>
</DropdownItem>
arivepr marked this conversation as resolved.
Show resolved Hide resolved
),
<DropdownItem key="notification preferences" onClick={() => onNavigateTo('/settings/notifications/user-preferences')}>
<Flex>
<FlexItem>Manage my notification preferences</FlexItem>
<FlexItem align={{ default: 'alignRight' }}>
<Icon className="pf-v5-u-ml-auto">
<ExternalLinkAltIcon />
</Icon>
</FlexItem>
</Flex>
</DropdownItem>,
arivepr marked this conversation as resolved.
Show resolved Hide resolved
];

const filterDropdownItems = () => {
return [
<DropdownGroup key="filter-label" label="Show notifications for...">
<DropdownList>
{filterConfig.map((source) => (
<DropdownItem key={source.value} onClick={() => onFilterSelect(source.value)} isDisabled={notifications.length === 0}>
<Checkbox isChecked={activeFilters.includes(source.value)} id={source.value} className="pf-v5-u-mr-sm" />
{source.title}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkbox is partially uncontrolled which prints an error in a console.

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

There is a build in PF way of creating menu item checkboxes: https://www.patternfly.org/components/menus/menu

DropdownItem shares props with MenuItems. It's just a wrapper around it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I misunderstood what you meant with the menu wrapper before 👍

</DropdownItem>
))}
<Divider />
<DropdownItem key="reset-filters" onClick={() => setActiveFilters([])}>
<Button variant="link" isDisabled={activeFilters.length === 0} isInline>
Reset filters
arivepr marked this conversation as resolved.
Show resolved Hide resolved
</Button>
</DropdownItem>
</DropdownList>
</DropdownGroup>,
];
};

const renderNotifications = () => {
if (notifications.length === 0) {
return <EmptyNotifications />;
}

const sortedNotifications = orderBy(activeFilters?.length > 0 ? filteredNotifications : notifications, ['read', 'created'], ['asc', 'asc']);

return sortedNotifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} onNavigateTo={onNavigateTo} />
));
};

return (
<NotificationDrawer ref={innerRef}>
<NotificationDrawerHeader onClose={() => dispatch(toggleNotificationsDrawer())}>
<Button variant="plain" aria-label="Filter">
<FilterIcon />
</Button>
<Button variant="plain" aria-label="Mark as read">
<EllipsisVIcon />
</Button>
<NotificationDrawerHeader onClose={onNotificationsDrawerClose} title="Notifications" className="pf-u-align-items-center">
{activeFilters.length > 0 && <Badge isRead>{activeFilters.length}</Badge>}
<Dropdown
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={() => setIsFilterDropdownOpen(!isFilterDropdownOpen)}
id="notifications-filter-toggle"
variant="plain"
>
<FilterIcon />
</MenuToggle>
)}
isOpen={isFilterDropdownOpen}
onOpenChange={setIsFilterDropdownOpen}
popperProps={{
position: PopoverPosition.right,
}}
id="notifications-filter-dropdown"
aria-label="Notifications filter"
>
{filterDropdownItems()}
</Dropdown>
<Dropdown
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dropdown is missing the onOpenChange prop to properly react to click events. You are also missing the popperProps config to ensure the dropdown menu is not hiding behind the edge of the screen.

<MenuToggle
ref={toggleRef}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
variant="plain"
id="notifications-actions-toggle"
isFullWidth
>
<EllipsisVIcon />
</MenuToggle>
)}
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
popperProps={{
position: PopoverPosition.right,
}}
id="notifications-actions-dropdown"
>
<DropdownList>{dropdownItems}</DropdownList>
arivepr marked this conversation as resolved.
Show resolved Hide resolved
</Dropdown>
</NotificationDrawerHeader>
{notifications.length === 0 ? <EmptyNotifications /> : 'TODO'}
<NotificationDrawerBody>
<NotificationDrawerList>{renderNotifications()}</NotificationDrawerList>
</NotificationDrawerBody>
</NotificationDrawer>
);
};
Expand Down
79 changes: 79 additions & 0 deletions src/components/NotificationsDrawer/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -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<NotificationItemProps> = ({ 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 = [
<DropdownItem key="read" onClick={onCheckboxToggle}>{`Mark as ${!notification.read ? 'read' : 'unread'}`}</DropdownItem>,
<DropdownItem key="manage-event" onClick={() => onNavigateTo('settings/notifications/configure-events')}>
Manage this event
</DropdownItem>,
];
return (
<React.Fragment>
<NotificationDrawerList>
<NotificationDrawerListItem variant="info" isRead={notification.read}>
<NotificationDrawerListItemHeader title={notification.title} srTitle="Info notification:">
<Checkbox isChecked={notification.read} onChange={onCheckboxToggle} id="read-checkbox" name="read-checkbox" />
<Dropdown
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
arivepr marked this conversation as resolved.
Show resolved Hide resolved
<MenuToggle
ref={toggleRef}
aria-label="Notification actions dropdown"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
id="notification-item-toggle"
isExpanded={isDropdownOpen}
variant="plain"
>
<EllipsisVIcon />
</MenuToggle>
)}
isOpen={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
popperProps={{
position: PopoverPosition.right,
}}
id="notification-item-dropdown"
>
<DropdownList>{notificationDropdownItems}</DropdownList>
</Dropdown>
</NotificationDrawerListItemHeader>
<NotificationDrawerListItemBody timestamp={<DateFormat date={notification.created} />}>
<Label variant="outline" isCompact className="pf-u-mb-md">
{notification.source}
</Label>
karelhala marked this conversation as resolved.
Show resolved Hide resolved
<span className="pf-u-display-block">{notification.description}</span>
</NotificationDrawerListItemBody>
</NotificationDrawerListItem>
</NotificationDrawerList>
</React.Fragment>
);
};

export default NotificationItem;
Original file line number Diff line number Diff line change
@@ -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' },
];
5 changes: 5 additions & 0 deletions src/redux/action-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
18 changes: 18 additions & 0 deletions src/redux/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Loading
Loading