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; +}