Skip to content

Commit

Permalink
frontend: Support for Workflow / Entire App Notification (#3084)
Browse files Browse the repository at this point in the history
### 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
<img width="864" alt="Screenshot 2024-08-26 at 2 57 54 p m"
src="https://github.com/user-attachments/assets/4905476d-68da-4752-8148-4cde449fa449">

- Header and Multi Workflow notification
<img width="1722" alt="Screenshot 2024-08-26 at 2 59 53 p m"
src="https://github.com/user-attachments/assets/ccaa97a6-5182-438f-8510-44c54c2b872a">

### 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>
  • Loading branch information
lucechal14 and jdslaugh authored Sep 5, 2024
1 parent 7da29eb commit 008ef58
Show file tree
Hide file tree
Showing 13 changed files with 409 additions and 17 deletions.
6 changes: 5 additions & 1 deletion frontend/packages/core/src/AppLayout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -73,6 +75,7 @@ const StyledLogo = styled("img")({
const Header: React.FC<HeaderProps> = ({
title = "clutch",
logo = <Logo />,
banners,
enableNPS = false,
search = true,
feedback = true,
Expand All @@ -88,6 +91,7 @@ const Header: React.FC<HeaderProps> = ({
<Link to="/">{typeof logo === "string" ? <StyledLogo src={logo} /> : logo}</Link>
<Title>{title}</Title>
<Grid container alignItems="center" justifyContent="flex-end">
<AppNotification type="header" banners={banners} />
{search && (
<Box>
<SearchField />
Expand Down
2 changes: 1 addition & 1 deletion frontend/packages/core/src/AppLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
9 changes: 9 additions & 0 deletions frontend/packages/core/src/AppLayout/tests/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
74 changes: 74 additions & 0 deletions frontend/packages/core/src/AppNotifications/HeaderNotification.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<Grid item xs={4}>
<StyledAlert
severity={headerBannerData?.severity || "info"}
elevation={6}
onClose={onDismissAlertHeader}
>
<StyledAlertContent>
<StyledMessage>{headerBannerData.message}</StyledMessage>
{headerBannerData?.link && headerBannerData?.linkText && (
<StyledLink>
<LinkComponent href={headerBannerData?.link}>
{headerBannerData?.linkText}
</LinkComponent>
</StyledLink>
)}
</StyledAlertContent>
</StyledAlert>
</Grid>
)}
</>
);
};

export default HeaderNotification;
Original file line number Diff line number Diff line change
@@ -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 && (
<Grid container justifyContent="center" pt={2} pb={1} px={3}>
<Grid item xs>
{showAlertPerWorkflow && (
<Alert
severity={perWorkflowData[workflow]?.severity || "info"}
title={perWorkflowData[workflow]?.title}
elevation={6}
onClose={onDismissAlertPerWorkflow}
>
{perWorkflowData[workflow]?.message}
{perWorkflowData[workflow]?.link && perWorkflowData[workflow]?.linkText && (
<LinkComponent href={perWorkflowData[workflow]?.link}>
{perWorkflowData[workflow]?.linkText}
</LinkComponent>
)}
</Alert>
)}
{showAlertMultiWorkflow && !showAlertPerWorkflow && (
<Alert
severity={multiWorkflowData?.severity || "info"}
title={multiWorkflowData?.title}
elevation={6}
onClose={onDismissAlertMultiWorkflow}
>
{multiWorkflowData?.message}
{multiWorkflowData?.link && multiWorkflowData?.linkText && (
<LinkComponent href={multiWorkflowData?.link}>
{multiWorkflowData?.linkText}
</LinkComponent>
)}
</Alert>
)}
</Grid>
</Grid>
)}
{children}
</>
);
};

export default LayoutWithNotifications;
54 changes: 54 additions & 0 deletions frontend/packages/core/src/AppNotifications/index.tsx
Original file line number Diff line number Diff line change
@@ -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" ? (
<HeaderNotification bannersData={bannersData as AppBanners} onDismissAlert={onDismissAlert} />
) : (
<LayoutWithNotifications
workflow={workflow}
bannersData={bannersData as AppBanners}
onDismissAlert={onDismissAlert}
>
{children}
</LayoutWithNotifications>
);
};

export default AppNotification;
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 008ef58

Please sign in to comment.