Skip to content

Commit

Permalink
feat: add floating notifications for applicable events
Browse files Browse the repository at this point in the history
- setup floating notification provider
- applied floating notifications to all relevant instance actions
- applied floating notifications to all relevant cluster actions
- applied floating notifications to all relevant profile actions
- applied floating notifications to all relevant network actions
- applied floating notifications to all relevant storage actions
- applied floating notifications to all relevant image actions
- applied floating notifications to all relevant operations actions
- applied floating notifications to all relevant project actions
- applied floating notifications to all relevant settings actions
- removed NotificationRowLegacy component
- removed NotificationRow from NetworkMap, not needed
- added filtering by severity for toast notifications

Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Jan 30, 2024
1 parent da6e258 commit 911f284
Show file tree
Hide file tree
Showing 89 changed files with 1,130 additions and 465 deletions.
13 changes: 7 additions & 6 deletions src/Root.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import React, { FC } from "react";
import Navigation from "components/Navigation";
import {
NotificationProvider,
QueuedNotification,
} from "@canonical/react-components";
import { QueuedNotification } from "@canonical/react-components";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Panels from "components/Panels";
import { AuthProvider } from "context/auth";
Expand All @@ -15,6 +12,7 @@ import App from "./App";
import ErrorBoundary from "components/ErrorBoundary";
import ErrorPage from "components/ErrorPage";
import { useLocation } from "react-router-dom";
import NewNotificationProvider from "context/notificationProvider";

const queryClient = new QueryClient();

Expand All @@ -23,7 +21,10 @@ const Root: FC = () => {

return (
<ErrorBoundary fallback={ErrorPage}>
<NotificationProvider state={location.state} pathname={location.pathname}>
<NewNotificationProvider
state={location.state}
pathname={location.pathname}
>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ProjectProvider>
Expand All @@ -42,7 +43,7 @@ const Root: FC = () => {
</ProjectProvider>
</AuthProvider>
</QueryClientProvider>
</NotificationProvider>
</NewNotificationProvider>
</ErrorBoundary>
);
};
Expand Down
63 changes: 0 additions & 63 deletions src/components/NotificationRowLegacy.tsx

This file was deleted.

191 changes: 191 additions & 0 deletions src/components/ToastNotifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { ICONS, Icon, Notification } from "@canonical/react-components";
import { DefaultTitles } from "@canonical/react-components/dist/components/Notification/Notification";
import {
GroupedNotificationCount,
ToastNotificationType,
} from "context/toastNotificationProvider";
import React, { FC, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import classnames from "classnames";
import { Filters, iconLookup, severityOrder } from "util/notifications";

interface Props {
notifications: ToastNotificationType[];
onDismiss: (notification: ToastNotificationType) => void;
onDismissAll: () => void;
onDismissByFilter: (
notifications: ToastNotificationType[],
filters: Filters,
) => void;
groupedCount: GroupedNotificationCount;
}

const ToastNotifications: FC<Props> = ({
notifications,
onDismiss,
onDismissAll,
onDismissByFilter,
groupedCount = {},
}) => {
const [expanded, setExpanded] = useState(false);
const [filters, setFilters] = useState<Filters>({});
const hasFilters = !!Object.keys(filters).length;

useEffect(() => {
if (notifications.length < 2) {
setExpanded(false);
setFilters({});
}
}, [notifications.length]);

const handleFilterSelect = (filter: string) => {
setFilters((prev) => {
const newFilters = { ...prev };
if (!newFilters[filter]) {
newFilters[filter] = true;
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete newFilters[filter];
}
return newFilters;
});
};

const handleExpansion = () => {
setExpanded((prev) => !prev);
if (expanded) {
setFilters({});
}
};

const handleDismissGroups = () => {
if (hasFilters) {
onDismissByFilter(notifications, filters);
setFilters({});
return;
}

onDismissAll();
};

const getSeverityFilters = () => {
const filterButtons = severityOrder.map((severity) => {
if (groupedCount[severity]) {
return (
<button
key={`notification-filter-${severity}`}
className={classnames(
"u-no-margin u-no-padding u-no-border filter-button",
{ "filter-button-active": filters[severity] },
)}
onClick={() => handleFilterSelect(severity)}
disabled={!expanded}
>
<Icon name={iconLookup[severity]} />
<span>{groupedCount[severity]}</span>
</button>
);
}
});

return <div className="filters">{filterButtons}</div>;
};

const getDismissText = () => {
if (hasFilters) {
const dismissText = (
<div className="filters">
<span className="filter-text">Dismiss</span>
{severityOrder.map((severity) => {
if (groupedCount[severity] && filters[severity]) {
return (
<span key={`dismiss-text-${severity}`} className="filter-text">
<span>{groupedCount[severity]}</span>
<Icon name={iconLookup[severity]} />
</span>
);
}
})}
</div>
);
return dismissText;
}

return <span>Dismiss all {notifications.length}</span>;
};

// Only filter input data if there are filters present
const filteredNotifications = hasFilters
? notifications.filter((notification) => filters[notification.type])
: notifications;

const lastNotificationIndex = filteredNotifications.length - 1;
const latestNotification = filteredNotifications[lastNotificationIndex];
// Don't assign alert role for notifications when expanded since we don't want
// screen readers to announce every existing notification
const notificationEls = expanded ? (
filteredNotifications.map((_, index, array) => {
const lastNotificationIndex = array.length - 1;
// This will map notifications in reverse order
const notification = array[lastNotificationIndex - index];
return (
<Notification
key={`notification-${index}`}
title={notification.title ?? DefaultTitles[notification.type]}
actions={notification.actions}
severity={notification.type}
onDismiss={() => onDismiss(notification)}
className={`u-no-margin--bottom toast-notification ${index === lastNotificationIndex ? "toast-notification--no-border-bottom" : ""}`}
timestamp={notification.timestamp}
>
{notification.message}
</Notification>
);
})
) : (
<Notification
title={latestNotification.title ?? DefaultTitles[latestNotification.type]}
actions={latestNotification.actions}
severity={latestNotification.type}
onDismiss={() => onDismiss(latestNotification)}
className="u-no-margin--bottom toast-notification"
timestamp={latestNotification.timestamp}
role="alert"
>
{latestNotification.message}
</Notification>
);

const hasMultipleNotifications = notifications.length > 1;
return (
<>
{createPortal(
<div className="toast-container" role="alertdialog">
{hasMultipleNotifications && (
<div className="top-summary">
{getSeverityFilters()}
<button
className="u-has-icon u-no-margin u-no-padding u-no-border show-hide-button"
onClick={handleExpansion}
>
<span>{expanded ? "Hide" : "Show all"}</span>
<Icon name={expanded ? ICONS.chevronDown : ICONS.chevronUp} />
</button>
</div>
)}
{notificationEls}
{hasMultipleNotifications && (
<div className="dismiss" onClick={handleDismissGroups}>
<button className="u-no-margin u-no-padding u-no-border dismiss-button">
{getDismissText()}
</button>
</div>
)}
</div>,
document.body,
)}
</>
);
};

export default ToastNotifications;
20 changes: 20 additions & 0 deletions src/context/notificationProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
NotificationProvider,
QueuedNotification,
} from "@canonical/react-components";
import React, { FC, PropsWithChildren } from "react";
import ToastNotificationProvider from "./toastNotificationProvider";

const NewNotificationProvider: FC<
PropsWithChildren<{ state: QueuedNotification["state"]; pathname?: string }>
> = ({ children, state, pathname }) => {
return (
<ToastNotificationProvider>
<NotificationProvider state={state} pathname={pathname}>
{children}
</NotificationProvider>
</ToastNotificationProvider>
);
};

export default NewNotificationProvider;
Loading

0 comments on commit 911f284

Please sign in to comment.