From 008ef584cad5fc4d7b6119a4eb4e9f28c0f6ebc3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Luc=C3=ADa=20Echenique=20=C3=81lvarez?=
<25833665+lucechal14@users.noreply.github.com>
Date: Thu, 5 Sep 2024 12:55:33 -0600
Subject: [PATCH] frontend: Support for Workflow / Entire App Notification
(#3084)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### Description
Support for Notifications across the app. We have 3 types of
notifications:
1. **Header**: displayed in the header of the page with dynamic message,
link and severity.
2. **Per Workflow:** displayed at the top of the main section of the
page for each workflow specified in the configuration of the
notifications with dynamic title, message, link and severity per
workflow.
3. **Multi Workflow**: displayed at the top of the main section of the
page for the workflows specified in the configuration of the
notifications with dynamic title, message, link and severity.
### Examples
- Header and Per Workflow notification
- Header and Multi Workflow notification
### Testing
You can use an object similar to this to test:
`const banners = {
header: {
message: "header message",
linkText: "link",
link: "linkhere",
severity: "info",
},
perWorkflow: {
workflowName1: {
title: "per workflow title for test 2",
message: "per workflow message for test 2",
linkText: "link",
link: "linkhere",
severity: "info",
},
workflowName2: {
title: "per workflow title for test 3",
message: "per workflow message for test 3",
linkText: "link",
link: "linkhere",
severity: "info",
},
},
multiWorkflow: {
title: "multi workflow title",
message: "multi workflow message",
workflows: ["workflowName1", "workflowName3"],
severity: "info",
linkText: "link",
link: "linkhere",
},
};`
Send this const as _**banners**_ in the _**appConfiguration**_ prop of
your _**ClutchApp**_
---------
Co-authored-by: Josh Slaughter <8338893+jdslaugh@users.noreply.github.com>
---
.../packages/core/src/AppLayout/header.tsx | 6 +-
.../packages/core/src/AppLayout/index.tsx | 2 +-
.../core/src/AppLayout/tests/layout.test.tsx | 9 ++
.../AppNotifications/HeaderNotification.tsx | 74 ++++++++++++++
.../LayoutWithNotifications.tsx | 99 +++++++++++++++++++
.../core/src/AppNotifications/index.tsx | 54 ++++++++++
.../useCompareAppNotificationsData.tsx | 80 +++++++++++++++
.../packages/core/src/AppProvider/index.tsx | 42 +++++---
.../core/src/Contexts/preferences-context.tsx | 5 +
frontend/packages/core/src/Feedback/alert.tsx | 11 ++-
frontend/packages/core/src/Types/app.tsx | 9 ++
frontend/packages/core/src/Types/index.tsx | 2 +
.../packages/core/src/Types/notification.tsx | 33 +++++++
13 files changed, 409 insertions(+), 17 deletions(-)
create mode 100644 frontend/packages/core/src/AppNotifications/HeaderNotification.tsx
create mode 100644 frontend/packages/core/src/AppNotifications/LayoutWithNotifications.tsx
create mode 100644 frontend/packages/core/src/AppNotifications/index.tsx
create mode 100644 frontend/packages/core/src/AppNotifications/useCompareAppNotificationsData.tsx
create mode 100644 frontend/packages/core/src/Types/app.tsx
create mode 100644 frontend/packages/core/src/Types/index.tsx
create mode 100644 frontend/packages/core/src/Types/notification.tsx
diff --git a/frontend/packages/core/src/AppLayout/header.tsx b/frontend/packages/core/src/AppLayout/header.tsx
index 6100e8f403..b23a7ad805 100644
--- a/frontend/packages/core/src/AppLayout/header.tsx
+++ b/frontend/packages/core/src/AppLayout/header.tsx
@@ -3,9 +3,10 @@ import { Link } from "react-router-dom";
import styled from "@emotion/styled";
import { AppBar as MuiAppBar, Box, Grid, Theme, Toolbar, Typography } from "@mui/material";
-import type { AppConfiguration } from "../AppProvider";
+import AppNotification from "../AppNotifications";
import { FeatureOn, SimpleFeatureFlag } from "../flags";
import { NPSHeader } from "../NPS";
+import type { AppBanners, AppConfiguration } from "../Types";
import Logo from "./logo";
import Notifications from "./notifications";
@@ -43,6 +44,7 @@ interface HeaderProps extends AppConfiguration {
* Will enable the user information component in the header
*/
userInfo?: boolean;
+ banners?: AppBanners;
}
const AppBar = styled(MuiAppBar)(({ theme }: { theme: Theme }) => ({
@@ -73,6 +75,7 @@ const StyledLogo = styled("img")({
const Header: React.FC = ({
title = "clutch",
logo = ,
+ banners,
enableNPS = false,
search = true,
feedback = true,
@@ -88,6 +91,7 @@ const Header: React.FC = ({
{typeof logo === "string" ? : logo}
{title}
+
{search && (
diff --git a/frontend/packages/core/src/AppLayout/index.tsx b/frontend/packages/core/src/AppLayout/index.tsx
index 96487bf10a..4510e13e53 100644
--- a/frontend/packages/core/src/AppLayout/index.tsx
+++ b/frontend/packages/core/src/AppLayout/index.tsx
@@ -3,8 +3,8 @@ import { Outlet } from "react-router-dom";
import styled from "@emotion/styled";
import { Grid as MuiGrid } from "@mui/material";
-import type { AppConfiguration } from "../AppProvider";
import Loadable from "../loading";
+import type { AppConfiguration } from "../Types";
import Drawer from "./drawer";
import Header, { APP_BAR_HEIGHT } from "./header";
diff --git a/frontend/packages/core/src/AppLayout/tests/layout.test.tsx b/frontend/packages/core/src/AppLayout/tests/layout.test.tsx
index 353f75ad34..f863843357 100644
--- a/frontend/packages/core/src/AppLayout/tests/layout.test.tsx
+++ b/frontend/packages/core/src/AppLayout/tests/layout.test.tsx
@@ -5,11 +5,20 @@ import { render, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import * as appContext from "../../Contexts/app-context";
+import * as preferencesContext from "../../Contexts/preferences-context";
import { client } from "../../Network";
import { ThemeProvider } from "../../Theme";
import AppLayout from "..";
jest.spyOn(appContext, "useAppContext").mockReturnValue({ workflows: [] });
+jest.spyOn(preferencesContext, "useUserPreferences").mockReturnValue({
+ timeFormat: "UTC",
+ banners: {
+ header: {},
+ multiWorkflow: {},
+ perWorkflow: {},
+ },
+});
jest.spyOn(client, "post").mockReturnValue(
new Promise((resolve, reject) => {
resolve({
diff --git a/frontend/packages/core/src/AppNotifications/HeaderNotification.tsx b/frontend/packages/core/src/AppNotifications/HeaderNotification.tsx
new file mode 100644
index 0000000000..a72a6c6069
--- /dev/null
+++ b/frontend/packages/core/src/AppNotifications/HeaderNotification.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import styled from "@emotion/styled";
+import isEmpty from "lodash/isEmpty";
+
+import { Alert } from "../Feedback";
+import Grid from "../grid";
+import { Link as LinkComponent } from "../link";
+import type { AppBanners } from "../Types";
+
+const StyledAlert = styled(Alert)({
+ padding: "8px 16px 8px 16px",
+ justifyContent: "center",
+ alignItems: "center",
+});
+
+const StyledAlertContent = styled.div({
+ display: "flex",
+ maxHeight: "40px",
+ overflowY: "auto",
+});
+
+const StyledMessage = styled.div({
+ flexWrap: "wrap",
+});
+
+const StyledLink = styled.div({
+ marginLeft: "10px",
+});
+
+interface HeaderNotificationProps {
+ bannersData: AppBanners;
+ onDismissAlert: (updatedData: AppBanners) => void;
+}
+
+const HeaderNotification = ({ bannersData, onDismissAlert }: HeaderNotificationProps) => {
+ const headerBannerData = bannersData?.header;
+
+ const onDismissAlertHeader = () => {
+ onDismissAlert({
+ ...bannersData,
+ header: {
+ ...headerBannerData,
+ dismissed: true,
+ },
+ });
+ };
+
+ return (
+ <>
+ {!isEmpty(headerBannerData) && !headerBannerData?.dismissed && (
+
+
+
+ {headerBannerData.message}
+ {headerBannerData?.link && headerBannerData?.linkText && (
+
+
+ {headerBannerData?.linkText}
+
+
+ )}
+
+
+
+ )}
+ >
+ );
+};
+
+export default HeaderNotification;
diff --git a/frontend/packages/core/src/AppNotifications/LayoutWithNotifications.tsx b/frontend/packages/core/src/AppNotifications/LayoutWithNotifications.tsx
new file mode 100644
index 0000000000..578e7b4a73
--- /dev/null
+++ b/frontend/packages/core/src/AppNotifications/LayoutWithNotifications.tsx
@@ -0,0 +1,99 @@
+import React from "react";
+import isEmpty from "lodash/isEmpty";
+
+import { Alert } from "../Feedback";
+import Grid from "../grid";
+import { Link as LinkComponent } from "../link";
+import type { AppBanners } from "../Types";
+
+interface LayoutWithNotificationsProps {
+ bannersData: AppBanners;
+ onDismissAlert: (updatedData: AppBanners) => void;
+ children: React.ReactNode;
+ workflow?: string;
+}
+
+const LayoutWithNotifications = ({
+ bannersData,
+ onDismissAlert,
+ children,
+ workflow,
+}: LayoutWithNotificationsProps) => {
+ const perWorkflowData = bannersData?.perWorkflow;
+ const multiWorkflowData = bannersData?.multiWorkflow;
+
+ const showAlertPerWorkflow =
+ workflow && perWorkflowData[workflow] && !perWorkflowData[workflow]?.dismissed;
+ const showAlertMultiWorkflow =
+ showAlertPerWorkflow || perWorkflowData[workflow]?.dismissed
+ ? false
+ : workflow &&
+ multiWorkflowData?.workflows?.includes(workflow) &&
+ !multiWorkflowData?.dismissed;
+
+ const onDismissAlertPerWorkflow = () => {
+ onDismissAlert({
+ ...bannersData,
+ perWorkflow: {
+ ...perWorkflowData,
+ [workflow]: { ...perWorkflowData[workflow], dismissed: true },
+ },
+ });
+ };
+
+ const onDismissAlertMultiWorkflow = () => {
+ onDismissAlert({
+ ...bannersData,
+ multiWorkflow: {
+ ...multiWorkflowData,
+ dismissed: true,
+ },
+ });
+ };
+
+ const showContainer = !isEmpty(perWorkflowData) || !isEmpty(multiWorkflowData);
+
+ return (
+ <>
+ {showContainer && (
+
+
+ {showAlertPerWorkflow && (
+
+ {perWorkflowData[workflow]?.message}
+ {perWorkflowData[workflow]?.link && perWorkflowData[workflow]?.linkText && (
+
+ {perWorkflowData[workflow]?.linkText}
+
+ )}
+
+ )}
+ {showAlertMultiWorkflow && !showAlertPerWorkflow && (
+
+ {multiWorkflowData?.message}
+ {multiWorkflowData?.link && multiWorkflowData?.linkText && (
+
+ {multiWorkflowData?.linkText}
+
+ )}
+
+ )}
+
+
+ )}
+ {children}
+ >
+ );
+};
+
+export default LayoutWithNotifications;
diff --git a/frontend/packages/core/src/AppNotifications/index.tsx b/frontend/packages/core/src/AppNotifications/index.tsx
new file mode 100644
index 0000000000..1d2b5abc8e
--- /dev/null
+++ b/frontend/packages/core/src/AppNotifications/index.tsx
@@ -0,0 +1,54 @@
+import React, { useEffect } from "react";
+
+import type { AppBanners } from "../Types";
+
+import HeaderNotification from "./HeaderNotification";
+import LayoutWithNotifications from "./LayoutWithNotifications";
+import compareAppNotificationsData from "./useCompareAppNotificationsData";
+
+interface AppNotificationProps {
+ type: "header" | "layout";
+ banners: AppBanners;
+ workflow?: string;
+ children?: React.ReactNode;
+}
+
+const AppNotification = ({ type, banners, children, workflow }: AppNotificationProps) => {
+ const { shouldUpdate, bannersData, dispatch } = compareAppNotificationsData(banners);
+
+ useEffect(() => {
+ if (shouldUpdate) {
+ dispatch({
+ type: "SetPref",
+ payload: {
+ key: "banners",
+ value: bannersData,
+ },
+ });
+ }
+ }, [shouldUpdate]);
+
+ const onDismissAlert = (updatedData: AppBanners) => {
+ dispatch({
+ type: "SetPref",
+ payload: {
+ key: "banners",
+ value: updatedData,
+ },
+ });
+ };
+
+ return type === "header" ? (
+
+ ) : (
+
+ {children}
+
+ );
+};
+
+export default AppNotification;
diff --git a/frontend/packages/core/src/AppNotifications/useCompareAppNotificationsData.tsx b/frontend/packages/core/src/AppNotifications/useCompareAppNotificationsData.tsx
new file mode 100644
index 0000000000..c87df15e48
--- /dev/null
+++ b/frontend/packages/core/src/AppNotifications/useCompareAppNotificationsData.tsx
@@ -0,0 +1,80 @@
+import get from "lodash/get";
+import isEmpty from "lodash/isEmpty";
+import isEqual from "lodash/isEqual";
+
+import { useUserPreferences } from "../Contexts/preferences-context";
+import type { AppBanners } from "../Types";
+
+const useCompareAppNotificationsData = (banners: AppBanners) => {
+ const { preferences, dispatch } = useUserPreferences();
+ const bannersPreferences: AppBanners = get(preferences, "banners");
+
+ const bannersData = {
+ header: {},
+ multiWorkflow: {},
+ perWorkflow: {},
+ };
+ let shouldUpdate = false;
+
+ if (!isEmpty(banners?.header)) {
+ const headerPreferences = {
+ message: bannersPreferences?.header?.message,
+ linkText: bannersPreferences?.header.linkText,
+ link: bannersPreferences?.header.link,
+ severity: bannersPreferences?.header.severity,
+ };
+
+ if (!isEqual(banners?.header, headerPreferences)) {
+ bannersData.header = { ...banners?.header, dismissed: false };
+ shouldUpdate = true;
+ } else {
+ bannersData.header = { ...bannersPreferences?.header };
+ }
+ }
+
+ if (!isEmpty(banners?.multiWorkflow)) {
+ const multiWorkflowPreferences = {
+ title: bannersPreferences?.multiWorkflow?.title,
+ message: bannersPreferences?.multiWorkflow?.message,
+ workflows: bannersPreferences?.multiWorkflow.workflows,
+ link: bannersPreferences?.multiWorkflow.link,
+ linkText: bannersPreferences?.multiWorkflow.linkText,
+ severity: bannersPreferences?.multiWorkflow.severity,
+ };
+
+ if (!isEqual(banners?.multiWorkflow, multiWorkflowPreferences)) {
+ bannersData.multiWorkflow = { ...banners?.multiWorkflow, dismissed: false };
+ shouldUpdate = true;
+ } else {
+ bannersData.multiWorkflow = { ...bannersPreferences?.multiWorkflow };
+ }
+ }
+
+ if (!isEmpty(banners?.perWorkflow)) {
+ Object.keys(banners?.perWorkflow).forEach(key => {
+ if (bannersPreferences?.perWorkflow?.[key]) {
+ const perWorkflowPreferences = {
+ title: bannersPreferences?.perWorkflow?.[key]?.title,
+ message: bannersPreferences?.perWorkflow?.[key]?.message,
+ linkText: bannersPreferences?.perWorkflow?.[key].linkText,
+ link: bannersPreferences?.perWorkflow?.[key].link,
+ severity: bannersPreferences?.perWorkflow?.[key].severity,
+ };
+
+ if (!isEqual(banners?.perWorkflow?.[key], perWorkflowPreferences)) {
+ bannersData.perWorkflow[key] = { ...banners?.perWorkflow?.[key], dismissed: false };
+ shouldUpdate = true;
+ } else {
+ bannersData.perWorkflow[key] = bannersPreferences?.perWorkflow?.[key];
+ }
+ } else {
+ bannersData.perWorkflow[key] = { ...banners?.perWorkflow?.[key], dismissed: false };
+ shouldUpdate = true;
+ }
+ });
+ }
+
+ return { shouldUpdate, bannersData, dispatch };
+};
+
+export default useCompareAppNotificationsData;
diff --git a/frontend/packages/core/src/AppProvider/index.tsx b/frontend/packages/core/src/AppProvider/index.tsx
index 9b846577b2..39595f623a 100644
--- a/frontend/packages/core/src/AppProvider/index.tsx
+++ b/frontend/packages/core/src/AppProvider/index.tsx
@@ -4,6 +4,7 @@ import Bugsnag from "@bugsnag/js";
import BugsnagPluginReact from "@bugsnag/plugin-react";
import AppLayout from "../AppLayout";
+import AppNotification from "../AppNotifications";
import { ApplicationContext, ShortLinkContext, UserPreferencesProvider } from "../Contexts";
import type { HeaderItem, TriggeredHeaderData } from "../Contexts/app-context";
import type { ShortLinkContextProps } from "../Contexts/short-link-context";
@@ -13,6 +14,7 @@ import { FEATURE_FLAG_POLL_RATE, featureFlags } from "../flags";
import Landing from "../landing";
import type { ClutchError } from "../Network/errors";
import NotFound from "../not-found";
+import type { AppConfiguration } from "../Types";
import { registeredWorkflows } from "./registrar";
import ShortLinkProxy, { ShortLinkBaseRoute } from "./short-link-proxy";
@@ -32,13 +34,6 @@ export interface UserConfiguration {
};
}
-export interface AppConfiguration {
- /** Will override the title of the given application */
- title?: string;
- /** Supports a react node or a string representing a public assets path */
- logo?: React.ReactNode | string;
-}
-
/**
* Filter workflow routes using available feature flags.
* @param workflows a list of valid Workflow objects.
@@ -108,6 +103,7 @@ const ClutchApp = ({
React.useEffect(() => {
loadWorkflows();
const interval = setInterval(loadWorkflows, FEATURE_FLAG_POLL_RATE);
+
return () => clearInterval(interval);
}, []);
@@ -181,7 +177,21 @@ const ClutchApp = ({
/>
}
>
- {!hasCustomLanding && } />}
+ {!hasCustomLanding && (
+
+
+
+ }
+ />
+ )}
{workflows.map((workflow: Workflow) => {
const workflowPath = workflow.path.replace(/^\/+/, "").replace(/\/+$/, "");
const workflowKey = workflow.path.split("/")[0];
@@ -208,10 +218,18 @@ const ClutchApp = ({
, {
- ...route.componentProps,
- heading,
- })}
+ element={
+
+ {React.cloneElement(, {
+ ...route.componentProps,
+ heading,
+ })}
+
+ }
/>
);
})}
diff --git a/frontend/packages/core/src/Contexts/preferences-context.tsx b/frontend/packages/core/src/Contexts/preferences-context.tsx
index b582b93ea6..7f2e7a6964 100644
--- a/frontend/packages/core/src/Contexts/preferences-context.tsx
+++ b/frontend/packages/core/src/Contexts/preferences-context.tsx
@@ -9,6 +9,11 @@ type Dispatch = (action: Action) => void;
type UserPreferencesProviderProps = { children: React.ReactNode };
const DEFAULT_PREFERENCES: State = {
timeFormat: "UTC",
+ banners: {
+ header: {},
+ multiWorkflow: {},
+ perWorkflow: {},
+ },
} as any;
interface ContextProps {
preferences: State;
diff --git a/frontend/packages/core/src/Feedback/alert.tsx b/frontend/packages/core/src/Feedback/alert.tsx
index e4ee38845c..dcb64c230b 100644
--- a/frontend/packages/core/src/Feedback/alert.tsx
+++ b/frontend/packages/core/src/Feedback/alert.tsx
@@ -8,7 +8,10 @@ import { Alert as MuiAlert, AlertTitle as MuiAlertTitle, alpha, Grid, Theme } fr
import styled from "../styled";
-const StyledAlert = styled(MuiAlert)<{ severity: MuiAlertProps["severity"] }>(
+const StyledAlert = styled(MuiAlert)<{
+ $title: MuiAlertProps["title"];
+ severity: MuiAlertProps["severity"];
+}>(
({ theme }: { theme: Theme }) => ({
borderRadius: "8px",
padding: "16px",
@@ -17,6 +20,7 @@ const StyledAlert = styled(MuiAlert)<{ severity: MuiAlertProps["severity"] }>(
color: alpha(theme.palette.secondary[900], 0.75),
fontSize: "14px",
overflow: "auto",
+ display: "flex",
".MuiAlert-icon": {
marginRight: "16px",
padding: "0",
@@ -40,6 +44,7 @@ const StyledAlert = styled(MuiAlert)<{ severity: MuiAlertProps["severity"] }>(
};
return {
+ ...(props.$title ? {} : { alignItems: "end" }),
background: backgroundColors[props.severity],
};
}
@@ -79,13 +84,13 @@ export const SEVERITIES = Object.keys(iconMappings);
export interface AlertProps
extends Pick<
MuiAlertProps,
- "severity" | "action" | "onClose" | "elevation" | "variant" | "icon"
+ "severity" | "action" | "onClose" | "elevation" | "variant" | "icon" | "className"
> {
title?: React.ReactNode;
}
export const Alert: React.FC = ({ severity = "info", title, children, ...props }) => (
-
+
{title && {title}}
{children}
diff --git a/frontend/packages/core/src/Types/app.tsx b/frontend/packages/core/src/Types/app.tsx
new file mode 100644
index 0000000000..67443716ea
--- /dev/null
+++ b/frontend/packages/core/src/Types/app.tsx
@@ -0,0 +1,9 @@
+import type { AppBanners } from "./notification";
+
+export interface AppConfiguration {
+ /** Will override the title of the given application */
+ title?: string;
+ /** Supports a react node or a string representing a public assets path */
+ logo?: React.ReactNode | string;
+ banners?: AppBanners;
+}
diff --git a/frontend/packages/core/src/Types/index.tsx b/frontend/packages/core/src/Types/index.tsx
new file mode 100644
index 0000000000..773b780fd4
--- /dev/null
+++ b/frontend/packages/core/src/Types/index.tsx
@@ -0,0 +1,2 @@
+export * from "./app";
+export * from "./notification";
diff --git a/frontend/packages/core/src/Types/notification.tsx b/frontend/packages/core/src/Types/notification.tsx
new file mode 100644
index 0000000000..add3dddd3b
--- /dev/null
+++ b/frontend/packages/core/src/Types/notification.tsx
@@ -0,0 +1,33 @@
+import type { AlertProps as MuiAlertProps } from "@mui/lab";
+
+export interface AlertProps
+ extends Pick<
+ MuiAlertProps,
+ "severity" | "action" | "onClose" | "elevation" | "variant" | "icon" | "className"
+ > {
+ title?: React.ReactNode;
+}
+
+export interface Banner extends Pick {
+ message: string;
+ dismissed: boolean;
+ linkText?: string;
+ link?: string;
+}
+
+export interface PerWorkflowBanner {
+ [workflowName: string]: Banner;
+}
+
+export interface WorkflowsBanner extends Banner {
+ workflows: string[];
+}
+
+export interface AppBanners {
+ /** Will display a notification banner at the top of the application */
+ header?: Banner;
+ /** Allows for setting a notification banner on a per workflow basis */
+ perWorkflow?: PerWorkflowBanner;
+ /** Allows for setting a notification banner across multiple workflows */
+ multiWorkflow?: WorkflowsBanner;
+}