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 Screenshot 2024-08-26 at 2 57 54 p m - Header and Multi Workflow notification Screenshot 2024-08-26 at 2 59 53 p m ### 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; +}