diff --git a/package-lock.json b/package-lock.json
index 5ecc4e306e..f9b606914a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -76,6 +76,7 @@
"react-router-dom": "^6.16.0",
"react-scripts": "5.0.1",
"react-secure-storage": "^1.3.0",
+ "react-transition-group": "^4.4.5",
"stream-browserify": "^3.0.0",
"typescript": "^4.9.5",
"uuid": "^9.0.1",
diff --git a/package.json b/package.json
index 502bd89928..f460765b3f 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"react-router-dom": "^6.16.0",
"react-scripts": "5.0.1",
"react-secure-storage": "^1.3.0",
+ "react-transition-group": "^4.4.5",
"stream-browserify": "^3.0.0",
"typescript": "^4.9.5",
"uuid": "^9.0.1",
diff --git a/src/modules/Auth/layouts/AuthLayout/AuthLayout.tsx b/src/modules/Auth/layouts/AuthLayout/AuthLayout.tsx
index b8d7eab838..340031d840 100644
--- a/src/modules/Auth/layouts/AuthLayout/AuthLayout.tsx
+++ b/src/modules/Auth/layouts/AuthLayout/AuthLayout.tsx
@@ -1,7 +1,8 @@
import { Outlet } from 'react-router-dom';
import { auth } from 'modules/Auth/state';
-import { Spinner, Svg, Footer, Banner } from 'shared/components';
+import { Spinner, Svg, Footer } from 'shared/components';
+import { Banners } from 'shared/components/Banners';
import {
StyledAuthLayout,
@@ -22,7 +23,7 @@ export const AuthLayout = () => {
-
+
diff --git a/src/modules/Builder/components/Banners/AppletWithoutChangesBanner/AppletWithoutChangesBanner.tsx b/src/modules/Builder/components/Banners/AppletWithoutChangesBanner/AppletWithoutChangesBanner.tsx
new file mode 100644
index 0000000000..023de4937b
--- /dev/null
+++ b/src/modules/Builder/components/Banners/AppletWithoutChangesBanner/AppletWithoutChangesBanner.tsx
@@ -0,0 +1,13 @@
+import { useTranslation } from 'react-i18next';
+
+import { Banner, BannerProps } from 'shared/components/Banners/Banner';
+
+export const AppletWithoutChangesBanner = (props: BannerProps) => {
+ const { t } = useTranslation('app');
+
+ return (
+
+ {t('pleaseMakeChangesToApplet')}
+
+ );
+};
diff --git a/src/modules/Builder/components/Banners/AppletWithoutChangesBanner/index.ts b/src/modules/Builder/components/Banners/AppletWithoutChangesBanner/index.ts
new file mode 100644
index 0000000000..d4e51d3198
--- /dev/null
+++ b/src/modules/Builder/components/Banners/AppletWithoutChangesBanner/index.ts
@@ -0,0 +1 @@
+export * from './AppletWithoutChangesBanner';
diff --git a/src/modules/Builder/components/Banners/index.ts b/src/modules/Builder/components/Banners/index.ts
new file mode 100644
index 0000000000..d4e51d3198
--- /dev/null
+++ b/src/modules/Builder/components/Banners/index.ts
@@ -0,0 +1 @@
+export * from './AppletWithoutChangesBanner';
diff --git a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.test.tsx b/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.test.tsx
deleted file mode 100644
index b5a06c7605..0000000000
--- a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.test.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react';
-
-import { AppletWithoutChangesPopup } from './AppletWithoutChangesPopup';
-
-const mockOnClose = jest.fn();
-
-describe('AppletWithoutChangesPopup', () => {
- test('closes when the OK button is clicked', () => {
- render();
-
- fireEvent.click(screen.getByText('Ok'));
- expect(mockOnClose).toHaveBeenCalled();
- });
-});
diff --git a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.tsx b/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.tsx
deleted file mode 100644
index 1ded3f5419..0000000000
--- a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useTranslation } from 'react-i18next';
-
-import { Modal } from 'shared/components';
-import { StyledBodyLarge, StyledModalWrapper } from 'shared/styles';
-
-import { AppletWithoutChangesPopupProps } from './AppletWithoutChangesPopup.types';
-
-export const AppletWithoutChangesPopup = ({
- isPopupVisible,
- onClose,
-}: AppletWithoutChangesPopupProps) => {
- const { t } = useTranslation('app');
-
- return (
-
-
- {t('pleaseMakeChangesToApplet')}
-
-
- );
-};
diff --git a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.types.ts b/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.types.ts
deleted file mode 100644
index 5a99cbd33a..0000000000
--- a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/AppletWithoutChangesPopup.types.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export type AppletWithoutChangesPopupProps = {
- isPopupVisible: boolean;
- onClose: () => void;
-};
diff --git a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/index.ts b/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/index.ts
deleted file mode 100644
index 5eaf48093f..0000000000
--- a/src/modules/Builder/components/Popups/AppletWithoutChangesPopup/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './AppletWithoutChangesPopup';
diff --git a/src/modules/Builder/components/index.ts b/src/modules/Builder/components/index.ts
index acb164095b..1d37351c04 100644
--- a/src/modules/Builder/components/index.ts
+++ b/src/modules/Builder/components/index.ts
@@ -1,3 +1,4 @@
+export * from './Banners';
export * from './Item';
export * from './Header';
export * from './Uploads';
diff --git a/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.hooks.ts b/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.hooks.ts
index 558e2487d5..b39836852b 100644
--- a/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.hooks.ts
+++ b/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.hooks.ts
@@ -34,6 +34,7 @@ import {
FlowReportFieldsPrepareType,
getEntityReportFields,
} from 'modules/Builder/utils/getEntityReportFields';
+import { banners } from 'shared/state/Banners';
import {
getActivityItems,
@@ -256,7 +257,6 @@ export const useUpdatedAppletNavigate = () => {
export const useSaveAndPublishSetup = (
hasPrompt: boolean,
setIsFromLibrary?: Dispatch>,
- setAppletWithoutChangesPopupVisible?: (val: boolean) => void,
) => {
const {
trigger,
@@ -402,7 +402,7 @@ export const useSaveAndPublishSetup = (
}
if (!isDirty) {
- setAppletWithoutChangesPopupVisible?.(true);
+ dispatch(banners.actions.addBanner({ key: 'AppletWithoutChangesBanner' }));
return;
}
diff --git a/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.tsx b/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.tsx
index acff3a24d6..906a475cf7 100644
--- a/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.tsx
+++ b/src/modules/Builder/features/SaveAndPublish/SaveAndPublish.tsx
@@ -1,10 +1,8 @@
-import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { Svg } from 'shared/components/Svg';
import { SaveAndPublishProcessPopup } from 'modules/Builder/components/Popups/SaveAndPublishProcessPopup';
-import { AppletWithoutChangesPopup } from 'modules/Builder/components/Popups/AppletWithoutChangesPopup';
import { SaveChangesPopup } from 'modules/Builder/components';
import { Mixpanel } from 'shared/utils/mixpanel';
import {
@@ -20,8 +18,6 @@ import { SaveAndPublishProps } from './SaveAndPublish.types';
export const SaveAndPublish = ({ hasPrompt, setIsFromLibrary }: SaveAndPublishProps) => {
const { t } = useTranslation('app');
- const [appletWithoutChangesPopupVisible, setAppletWithoutChangesPopupVisible] = useState(false);
-
const {
isPasswordPopupOpened,
isPublishProcessPopupOpened,
@@ -36,7 +32,7 @@ export const SaveAndPublish = ({ hasPrompt, setIsFromLibrary }: SaveAndPublishPr
handleSaveChangesDoNotSaveSubmit,
handleSaveChangesSaveSubmit,
cancelNavigation,
- } = useSaveAndPublishSetup(hasPrompt, setIsFromLibrary, setAppletWithoutChangesPopupVisible);
+ } = useSaveAndPublishSetup(hasPrompt, setIsFromLibrary);
const { appletId } = useParams();
const handlePasswordSubmit = (ref?: AppletPasswordRefType) => {
@@ -56,10 +52,6 @@ export const SaveAndPublish = ({ hasPrompt, setIsFromLibrary }: SaveAndPublishPr
>
{t('saveAndPublish')}
- setAppletWithoutChangesPopupVisible(false)}
- />
setIsPasswordPopupOpened(false)}
diff --git a/src/redux/store/reducers.ts b/src/redux/store/reducers.ts
index 2248e89c87..4af1c41d76 100644
--- a/src/redux/store/reducers.ts
+++ b/src/redux/store/reducers.ts
@@ -3,6 +3,7 @@ import { combineReducers } from '@reduxjs/toolkit';
import { alerts } from 'shared/state/Alerts';
import { applet } from 'shared/state/Applet';
import { applets } from 'modules/Dashboard/state/Applets';
+import { banners } from 'shared/state/Banners';
import { calendarEvents } from 'modules/Dashboard/state/CalendarEvents';
import { popups } from 'modules/Dashboard/state/Popups';
import { users } from 'modules/Dashboard/state/Users';
@@ -17,6 +18,7 @@ export const rootReducer = combineReducers({
applet: applet.slice.reducer,
applets: applets.slice.reducer,
auth: auth?.slice.reducer,
+ banners: banners.slice.reducer,
calendarEvents: calendarEvents.slice.reducer,
library: library.slice.reducer,
popups: popups.slice.reducer,
diff --git a/src/resources/app-en.json b/src/resources/app-en.json
index 5e778735b3..5762595326 100644
--- a/src/resources/app-en.json
+++ b/src/resources/app-en.json
@@ -1246,7 +1246,7 @@
"fromToHint": "From {{min}} to {{max}}",
"selectValidInterval": "Select valid interval",
"selectValueWithinInterval": "Select a value within an interval",
- "bannerText": "<0>You are using the new version of MindLogger!0><1>End users must update to the new app.1><2>Take these steps now to ensure participant response data is not lost.2>",
+ "versionWarningBanner": "<0>You are using the new version of MindLogger!0> <1>End users must update to the new app.1> <2>Take these steps now to ensure participant response data is not lost.2>",
"youNeedToAuthorizeHint": "To create a new Applet or merge it with others you have to authorize.",
"errorFallback": {
"somethingWentWrong": "Something went wrong.",
diff --git a/src/resources/app-fr.json b/src/resources/app-fr.json
index 780b4f2d8f..f583aab67b 100644
--- a/src/resources/app-fr.json
+++ b/src/resources/app-fr.json
@@ -1246,7 +1246,7 @@
"fromToHint": "De {{min}} à {{max}}",
"selectValidInterval": "Sélectionnez un intervalle valide",
"selectValueWithinInterval": "Sélectionnez une valeur dans un intervalle",
- "bannerText": "<0>Vous utilisez la nouvelle version de MindLogger!0><1>Les utilisateurs finaux doivent mettre à jour vers la nouvelle application.1><2>Prenez ces mesures dès maintenant pour vous assurer que les données de réponse des participants ne sont pas perdues.2>",
+ "versionWarningBanner": "<0>Vous utilisez la nouvelle version de MindLogger!0> <1>Les utilisateurs finaux doivent mettre à jour vers la nouvelle application.1> <2>Prenez ces mesures dès maintenant pour vous assurer que les données de réponse des participants ne sont pas perdues.2>",
"youNeedToAuthorizeHint": "Pour créer une nouvelle applet ou la fusionner avec d'autres, vous devez autoriser.",
"errorFallback": {
"somethingWentWrong": "Quelque chose s'est mal passé.",
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 4a64519571..84bde35137 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -10,6 +10,7 @@ import { libraryRoutes } from 'modules/Library/routes';
import { authRoutes } from 'modules/Auth/routes';
import { auth } from 'redux/modules';
import { AppletNotFoundPopup } from 'shared/components';
+import { useSessionBanners } from 'shared/hooks/useSessionBanners';
import history from './history';
@@ -30,6 +31,8 @@ export const AppRoutes = () => {
}
}, [isAuthorized, token, dispatch]);
+ useSessionBanners();
+
return (
<>
{/* @ts-expect-error history-router now unstable and it's a known error https://github.com/remix-run/react-router/issues/9630 */}
diff --git a/src/shared/components/Banner/Banner.styles.ts b/src/shared/components/Banner/Banner.styles.ts
deleted file mode 100644
index c612d9ac16..0000000000
--- a/src/shared/components/Banner/Banner.styles.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Box, Link, styled } from '@mui/material';
-
-import theme from 'shared/styles/theme';
-import { variables } from 'shared/styles/variables';
-import { blendColorsNormal } from 'shared/utils/colors';
-
-export const StyledAlert = styled(Box)`
- background-color: ${blendColorsNormal(
- variables.palette.surface,
- variables.palette.yellow_alfa30,
- )};
-`;
-
-export const StyledWrapper = styled(Box)`
- min-height: 7.2rem;
- position: relative;
- color: ${variables.palette.on_surface};
- padding: ${theme.spacing(1.2, 5, 1.2, 1.6)};
- display: flex;
- align-items: center;
- justify-content: center;
-
- .close-btn {
- position: absolute;
- right: 1.2rem;
- top: 50%;
- transform: translateY(-50%);
- }
-`;
-
-export const StyledAlertContent = styled(Box)`
- position: relative;
- padding-left: ${theme.spacing(3.5)};
-
- .svg-exclamation-circle {
- position: absolute;
- left: 0;
- top: 50%;
- transform: translateY(-50%);
- fill: ${variables.palette.yellow};
- }
-
- p {
- margin-right: ${theme.spacing(0.5)};
- display: inline;
- }
-`;
-
-export const StyledLink = styled(Link)`
- color: ${variables.palette.primary};
- font-size: ${variables.font.size.lg};
- line-height: ${variables.font.lineHeight.lg};
- letter-spacing: ${variables.font.letterSpacing.md};
-
- &:hover {
- text-decoration: none;
- }
-`;
diff --git a/src/shared/components/Banner/Banner.test.tsx b/src/shared/components/Banner/Banner.test.tsx
deleted file mode 100644
index 6b5a5107cf..0000000000
--- a/src/shared/components/Banner/Banner.test.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-
-import { Banner } from './Banner';
-import { BANNER_LINK } from './Banner.const';
-
-describe('Banner', () => {
- test('clicking the close button hides the banner', async () => {
- render();
-
- const closeButton = screen.getByRole('button');
- fireEvent.click(closeButton);
- await waitFor(() => {
- expect(screen.queryByText('You are using the new version of MindLogger!')).not.toBeVisible();
- });
- });
-
- test('link has correct URL and opens in a new tab', () => {
- render();
-
- const link = screen.getByRole('link');
- expect(link).toHaveAttribute('href', BANNER_LINK);
- expect(link).toHaveAttribute('target', '_blank');
- });
-});
diff --git a/src/shared/components/Banner/Banner.tsx b/src/shared/components/Banner/Banner.tsx
deleted file mode 100644
index caf1fd4ade..0000000000
--- a/src/shared/components/Banner/Banner.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useState } from 'react';
-import { Collapse } from '@mui/material';
-import { Trans } from 'react-i18next';
-
-import { Svg } from 'shared/components/Svg';
-import {
- StyledIconButton,
- StyledTitleBoldMedium,
- StyledTitleMedium,
-} from 'shared/styles/styledComponents';
-
-import { StyledAlert, StyledAlertContent, StyledLink, StyledWrapper } from './Banner.styles';
-import { BANNER_LINK } from './Banner.const';
-
-export const Banner = () => {
- const [isVisible, setIsVisible] = useState(true);
-
- return (
-
-
-
-
-
-
-
- You are using the new version of MindLogger!
-
- End users must update to the new app.
-
- Take these steps now to ensure participant response data is not lost.
-
-
-
- setIsVisible(false)}>
-
-
-
-
-
- );
-};
diff --git a/src/shared/components/Banner/index.ts b/src/shared/components/Banner/index.ts
deleted file mode 100644
index bc95f09d62..0000000000
--- a/src/shared/components/Banner/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './Banner';
diff --git a/src/shared/components/Banners/Banner/Banner.const.tsx b/src/shared/components/Banners/Banner/Banner.const.tsx
new file mode 100644
index 0000000000..05c8316e66
--- /dev/null
+++ b/src/shared/components/Banners/Banner/Banner.const.tsx
@@ -0,0 +1,10 @@
+import { Svg } from 'shared/components/Svg';
+
+const getSvg = (id: string) => ;
+
+export const BANNER_ICONS = {
+ info: getSvg('more-info-filled'),
+ success: getSvg('check-circle'),
+ warning: getSvg('exclamation-circle'),
+ error: getSvg('exclamation-octagon'),
+};
diff --git a/src/shared/components/Banners/Banner/Banner.test.tsx b/src/shared/components/Banners/Banner/Banner.test.tsx
new file mode 100644
index 0000000000..5cb7be16da
--- /dev/null
+++ b/src/shared/components/Banners/Banner/Banner.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import { Banner } from './Banner';
+
+const mockOnClose = jest.fn();
+const mockUseWindowFocus = jest.fn();
+
+const props = {
+ children: 'Test banner',
+ onClose: mockOnClose,
+ duration: 5000,
+};
+
+jest.mock('shared/hooks/useWindowFocus', () => ({
+ useWindowFocus: () => mockUseWindowFocus(),
+}));
+
+describe('Banner', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ test('should render component', () => {
+ render();
+
+ expect(screen.queryByText(props.children)).toBeVisible();
+ const closeButton = screen.getByRole('button');
+ expect(closeButton).toBeVisible();
+ expect(closeButton).toHaveAccessibleName('Close');
+ });
+
+ test('clicking the close button calls onClose callback', () => {
+ render();
+
+ const closeButton = screen.getByRole('button');
+ fireEvent.click(closeButton);
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ test('banner auto-closes after 5 seconds if window in focus', () => {
+ jest.useFakeTimers();
+ mockUseWindowFocus.mockReturnValue(true);
+
+ render();
+
+ jest.advanceTimersByTime(props.duration + 1000);
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ test('banner does not auto-close if window unfocused', () => {
+ jest.useFakeTimers();
+ mockUseWindowFocus.mockReturnValue(false);
+
+ render();
+
+ jest.advanceTimersByTime(props.duration + 1000);
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/shared/components/Banners/Banner/Banner.tsx b/src/shared/components/Banners/Banner/Banner.tsx
new file mode 100644
index 0000000000..aeb8fae350
--- /dev/null
+++ b/src/shared/components/Banners/Banner/Banner.tsx
@@ -0,0 +1,53 @@
+import { Alert } from '@mui/material';
+import { useEffect, useState } from 'react';
+
+import { Svg } from 'shared/components/Svg';
+import { StyledClearedButton } from 'shared/styles';
+import { useWindowFocus } from 'shared/hooks/useWindowFocus';
+
+import { BannerProps } from './Banner.types';
+import { BANNER_ICONS } from './Banner.const';
+
+export const Banner = ({
+ children,
+ duration = 5000,
+ onClose,
+ hasCloseButton = !!onClose,
+ severity,
+}: BannerProps) => {
+ let timeoutId: NodeJS.Timeout | undefined;
+ const [isHovering, setIsHovering] = useState(false);
+ const isWindowFocused = useWindowFocus();
+
+ // Close banner on timeout only while window is focused & banner not hovered
+ // (a11y behavior adapted from MUI SnackBar)
+ useEffect(() => {
+ if (!duration || !onClose || isHovering || !isWindowFocused) return;
+
+ timeoutId = setTimeout(onClose, duration);
+
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }, [duration, isHovering, isWindowFocused, onClose]);
+
+ return (
+ ,
+ },
+ }}
+ onClose={hasCloseButton ? onClose : undefined}
+ onMouseEnter={() => setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ severity={severity}
+ >
+ {children}
+
+ );
+};
diff --git a/src/shared/components/Banners/Banner/Banner.types.ts b/src/shared/components/Banners/Banner/Banner.types.ts
new file mode 100644
index 0000000000..72e79d3b7c
--- /dev/null
+++ b/src/shared/components/Banners/Banner/Banner.types.ts
@@ -0,0 +1,9 @@
+import { AlertProps } from '@mui/material';
+
+export type BannerProps = {
+ /** @default 5000 */
+ duration?: number | null;
+ /** @default !!onClose */
+ hasCloseButton?: boolean;
+ onClose?: () => void;
+} & Pick;
diff --git a/src/shared/components/Banners/Banner/index.ts b/src/shared/components/Banners/Banner/index.ts
new file mode 100644
index 0000000000..226d5a42c4
--- /dev/null
+++ b/src/shared/components/Banners/Banner/index.ts
@@ -0,0 +1,2 @@
+export * from './Banner';
+export * from './Banner.types';
diff --git a/src/shared/components/Banners/Banners.test.tsx b/src/shared/components/Banners/Banners.test.tsx
new file mode 100644
index 0000000000..93fef997db
--- /dev/null
+++ b/src/shared/components/Banners/Banners.test.tsx
@@ -0,0 +1,35 @@
+import { PreloadedState } from '@reduxjs/toolkit';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+
+import { renderWithProviders } from 'shared/utils';
+import { RootState } from 'redux/store';
+
+import { Banners } from './Banners';
+
+const preloadedState: PreloadedState = {
+ banners: {
+ data: {
+ banners: [{ key: 'VersionWarningBanner' }],
+ },
+ },
+};
+
+describe('Banners', () => {
+ test('should render default banner', () => {
+ renderWithProviders(, { preloadedState });
+
+ expect(screen.getByText('You are using the new version of MindLogger!')).toBeInTheDocument();
+ });
+
+ test('should no longer render banner when its close button clicked', async () => {
+ renderWithProviders(, { preloadedState });
+
+ const button = screen.getByRole('button');
+ fireEvent.click(button);
+
+ // Wait for Collapse transition to complete
+ await waitFor(() => {
+ expect(screen.queryByText('You are using the new version of MindLogger!')).toBeNull();
+ });
+ });
+});
diff --git a/src/shared/components/Banners/Banners.tsx b/src/shared/components/Banners/Banners.tsx
new file mode 100644
index 0000000000..69eadf08be
--- /dev/null
+++ b/src/shared/components/Banners/Banners.tsx
@@ -0,0 +1,24 @@
+import { Collapse } from '@mui/material';
+import { TransitionGroup } from 'react-transition-group';
+
+import { BannerComponents, banners } from 'shared/state/Banners';
+import { useAppDispatch } from 'redux/store';
+
+export const Banners = () => {
+ const dispatch = useAppDispatch();
+ const data = banners.useData();
+
+ return (
+
+ {data.banners.map(({ key }) => {
+ const BannerComponent = BannerComponents[key];
+
+ return (
+
+ dispatch(banners.actions.removeBanner({ key }))} />
+
+ );
+ })}
+
+ );
+};
diff --git a/src/shared/components/Banner/Banner.const.ts b/src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.const.ts
similarity index 100%
rename from src/shared/components/Banner/Banner.const.ts
rename to src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.const.ts
diff --git a/src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.test.tsx b/src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.test.tsx
new file mode 100644
index 0000000000..2e4e9415e4
--- /dev/null
+++ b/src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import { VersionWarningBanner } from './VersionWarningBanner';
+import { BANNER_LINK } from './VersionWarningBanner.const';
+
+const mockOnClose = jest.fn();
+
+describe('VersionWarningBanner', () => {
+ test('clicking the close button hides the banner', () => {
+ render();
+
+ const closeButton = screen.getByRole('button');
+ fireEvent.click(closeButton);
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ test('link has correct URL and opens in a new tab', () => {
+ render();
+
+ const link = screen.getByRole('link');
+ expect(link).toHaveAttribute('href', BANNER_LINK);
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+});
diff --git a/src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.tsx b/src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.tsx
new file mode 100644
index 0000000000..7438708fb4
--- /dev/null
+++ b/src/shared/components/Banners/VersionWarningBanner/VersionWarningBanner.tsx
@@ -0,0 +1,17 @@
+import { Link } from '@mui/material';
+import { Trans } from 'react-i18next';
+
+import { Banner, BannerProps } from '../Banner';
+import { BANNER_LINK } from './VersionWarningBanner.const';
+
+export const VersionWarningBanner = (props: BannerProps) => (
+
+
+ You are using the new version of MindLogger!
+ <>End users must update to the new app.>
+
+ Take these steps now to ensure participant response data is not lost.
+
+
+
+);
diff --git a/src/shared/components/Banners/VersionWarningBanner/index.ts b/src/shared/components/Banners/VersionWarningBanner/index.ts
new file mode 100644
index 0000000000..57c408755c
--- /dev/null
+++ b/src/shared/components/Banners/VersionWarningBanner/index.ts
@@ -0,0 +1 @@
+export * from './VersionWarningBanner';
diff --git a/src/shared/components/Banners/index.ts b/src/shared/components/Banners/index.ts
new file mode 100644
index 0000000000..2560b8e646
--- /dev/null
+++ b/src/shared/components/Banners/index.ts
@@ -0,0 +1 @@
+export * from './Banners';
diff --git a/src/shared/components/index.ts b/src/shared/components/index.ts
index ef3c64b622..03eaa21e7b 100644
--- a/src/shared/components/index.ts
+++ b/src/shared/components/index.ts
@@ -1,7 +1,6 @@
export * from './Actions';
export * from './AppletImage';
export * from './Avatar';
-export * from './Banner';
export * from './ButtonWithMenu';
export * from './Chip';
export * from './CropPopup';
diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts
index 0ba6f9ba0c..2dc1b3a8a7 100644
--- a/src/shared/hooks/index.ts
+++ b/src/shared/hooks/index.ts
@@ -19,3 +19,4 @@ export * from './useIntersectionObserver';
export * from './useNetwork';
export * from './useCheckIfAppletHasNotFoundError';
export * from './useRespondentLabel';
+export * from './useWindowFocus';
diff --git a/src/shared/hooks/useSessionBanners.test.ts b/src/shared/hooks/useSessionBanners.test.ts
new file mode 100644
index 0000000000..32386879d3
--- /dev/null
+++ b/src/shared/hooks/useSessionBanners.test.ts
@@ -0,0 +1,60 @@
+import { PreloadedState } from '@reduxjs/toolkit';
+
+import { auth } from 'redux/modules';
+import { authStorage, renderHookWithProviders } from 'shared/utils';
+import { RootState } from 'redux/store';
+
+import { useSessionBanners } from './useSessionBanners';
+
+const emptyState: PreloadedState = {
+ banners: {
+ data: {
+ banners: [],
+ },
+ },
+};
+
+const populatedState: PreloadedState = {
+ banners: {
+ data: {
+ banners: [{ key: 'VersionWarningBanner' }],
+ },
+ },
+};
+
+const spyAccessToken = jest.spyOn(authStorage, 'getAccessToken');
+const spyUseStatus = jest.spyOn(auth, 'useStatus');
+
+describe('useSessionBanners', () => {
+ test('should add a banner when the session becomes valid', () => {
+ spyAccessToken.mockReturnValue(null);
+ spyUseStatus.mockReturnValue('idle');
+
+ const { rerender, store } = renderHookWithProviders(useSessionBanners, {
+ preloadedState: emptyState,
+ });
+
+ spyAccessToken.mockReturnValue('access-token');
+ spyUseStatus.mockReturnValue('success');
+
+ rerender();
+
+ expect(store.getState().banners).toEqual(populatedState.banners);
+ });
+
+ test('should remove all banners when the session becomes invalid', () => {
+ spyAccessToken.mockReturnValue('access-token');
+ spyUseStatus.mockReturnValue('success');
+
+ const { rerender, store } = renderHookWithProviders(useSessionBanners, {
+ preloadedState: populatedState,
+ });
+
+ spyAccessToken.mockReturnValue(null);
+ spyUseStatus.mockReturnValue('idle');
+
+ rerender();
+
+ expect(store.getState().banners).toEqual(emptyState.banners);
+ });
+});
diff --git a/src/shared/hooks/useSessionBanners.ts b/src/shared/hooks/useSessionBanners.ts
new file mode 100644
index 0000000000..fb5e9aade2
--- /dev/null
+++ b/src/shared/hooks/useSessionBanners.ts
@@ -0,0 +1,28 @@
+import { useEffect, useRef } from 'react';
+
+import { banners } from 'shared/state/Banners';
+import { useAppDispatch } from 'redux/store';
+import { auth } from 'redux/modules';
+import { authStorage } from 'shared/utils';
+
+export const useSessionBanners = () => {
+ const dispatch = useAppDispatch();
+ const hasToken = !!authStorage.getAccessToken();
+ const status = auth.useStatus();
+ const isSessionValid = hasToken && status !== 'error';
+
+ const prevIsSessionValid = useRef(isSessionValid);
+ useEffect(() => {
+ // Only update banners when session status changes
+ if (prevIsSessionValid.current !== isSessionValid) {
+ if (isSessionValid) {
+ // Add version warning banner when logging in
+ dispatch(banners.actions.addBanner({ key: 'VersionWarningBanner' }));
+ } else {
+ dispatch(banners.actions.removeAllBanners());
+ }
+ }
+
+ prevIsSessionValid.current = isSessionValid;
+ }, [dispatch, isSessionValid]);
+};
diff --git a/src/shared/hooks/useWindowFocus.test.ts b/src/shared/hooks/useWindowFocus.test.ts
new file mode 100644
index 0000000000..b3954f78c6
--- /dev/null
+++ b/src/shared/hooks/useWindowFocus.test.ts
@@ -0,0 +1,45 @@
+import { act, renderHook } from '@testing-library/react';
+
+import { useWindowFocus } from './useWindowFocus';
+
+const fireFocusEvent = () => {
+ global.window.dispatchEvent(new Event('focus'));
+};
+
+const fireBlurEvent = () => {
+ global.window.dispatchEvent(new Event('blur'));
+};
+
+describe('useWindowFocus)', () => {
+ test('should default to false', () => {
+ const { result } = renderHook(() => useWindowFocus());
+
+ expect(result.current).toBe(false);
+ });
+
+ test('should be true after focus event', () => {
+ const { result } = renderHook(() => useWindowFocus());
+
+ act(() => {
+ fireFocusEvent();
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ test('should be false after blur event', () => {
+ const { result } = renderHook(() => useWindowFocus());
+
+ act(() => {
+ fireFocusEvent();
+ });
+
+ expect(result.current).toBe(true);
+
+ act(() => {
+ fireBlurEvent();
+ });
+
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/src/shared/hooks/useWindowFocus.ts b/src/shared/hooks/useWindowFocus.ts
new file mode 100644
index 0000000000..739fe353b4
--- /dev/null
+++ b/src/shared/hooks/useWindowFocus.ts
@@ -0,0 +1,27 @@
+import { useState, useEffect } from 'react';
+
+const hasFocus = () => typeof document !== 'undefined' && document.hasFocus();
+
+/**
+ * Monitors and returns boolean window focus state reactively.
+ */
+export const useWindowFocus = () => {
+ const [focused, setFocused] = useState(hasFocus);
+
+ useEffect(() => {
+ setFocused(hasFocus());
+
+ const onFocus = () => setFocused(true);
+ const onBlur = () => setFocused(false);
+
+ window.addEventListener('focus', onFocus);
+ window.addEventListener('blur', onBlur);
+
+ return () => {
+ window.removeEventListener('focus', onFocus);
+ window.removeEventListener('blur', onBlur);
+ };
+ }, []);
+
+ return focused;
+};
diff --git a/src/shared/layouts/BaseLayout/components/TopBar/TopBar.tsx b/src/shared/layouts/BaseLayout/components/TopBar/TopBar.tsx
index f9d624100c..cd9b42de5b 100644
--- a/src/shared/layouts/BaseLayout/components/TopBar/TopBar.tsx
+++ b/src/shared/layouts/BaseLayout/components/TopBar/TopBar.tsx
@@ -2,7 +2,8 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { Avatar, AvatarUiType, Svg, Banner } from 'shared/components';
+import { Avatar, AvatarUiType, Svg } from 'shared/components';
+import { Banners } from 'shared/components/Banners';
import { StyledBadge, StyledFlexTopCenter } from 'shared/styles';
import { page } from 'resources';
import { auth } from 'modules/Auth/state';
@@ -50,7 +51,7 @@ export const TopBar = () => {
)}
-
+
{visibleAccountDrawer && (
): void => {
+ state.data.banners.push(payload);
+ },
+ removeBanner: (state: BannersSchema, { payload }: PayloadAction): void => {
+ state.data.banners = state.data.banners.filter(({ key }) => key !== payload.key);
+ },
+ removeAllBanners: (state: BannersSchema): void => {
+ state.data.banners = [];
+ },
+};
diff --git a/src/shared/state/Banners/Banners.schema.ts b/src/shared/state/Banners/Banners.schema.ts
new file mode 100644
index 0000000000..df06bad70c
--- /dev/null
+++ b/src/shared/state/Banners/Banners.schema.ts
@@ -0,0 +1,17 @@
+import { AppletWithoutChangesBanner } from 'modules/Builder/components/Banners';
+import { VersionWarningBanner } from 'shared/components/Banners/VersionWarningBanner';
+
+export const BannerComponents = {
+ AppletWithoutChangesBanner,
+ VersionWarningBanner,
+};
+
+export type BannerPayload = {
+ key: keyof typeof BannerComponents;
+};
+
+export type BannersSchema = {
+ data: {
+ banners: Array;
+ };
+};
diff --git a/src/shared/state/Banners/Banners.state.tsx b/src/shared/state/Banners/Banners.state.tsx
new file mode 100644
index 0000000000..dde8f5da0a
--- /dev/null
+++ b/src/shared/state/Banners/Banners.state.tsx
@@ -0,0 +1,7 @@
+import { BannersSchema } from './Banners.schema';
+
+export const state: BannersSchema = {
+ data: {
+ banners: [],
+ },
+};
diff --git a/src/shared/state/Banners/index.ts b/src/shared/state/Banners/index.ts
new file mode 100644
index 0000000000..7a7f7bf2e1
--- /dev/null
+++ b/src/shared/state/Banners/index.ts
@@ -0,0 +1,21 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+import { useAppSelector } from 'redux/store/hooks';
+
+import { state as initialState } from './Banners.state';
+import { reducers } from './Banners.reducer';
+import { BannersSchema } from './Banners.schema';
+
+export * from './Banners.schema';
+
+const slice = createSlice({
+ name: 'banners',
+ initialState,
+ reducers,
+});
+
+export const banners = {
+ slice,
+ actions: slice.actions,
+ useData: (): BannersSchema['data'] => useAppSelector(({ banners: { data } }) => data),
+};
diff --git a/src/shared/state/index.ts b/src/shared/state/index.ts
index c728605b53..2ee79e3e40 100644
--- a/src/shared/state/index.ts
+++ b/src/shared/state/index.ts
@@ -2,3 +2,4 @@ export * from './Base';
export * from './Workspaces';
export * from './Applet';
export * from './Alerts';
+export * from './Banners';
diff --git a/src/shared/styles/theme.ts b/src/shared/styles/theme.ts
index f920514c54..83ec974762 100644
--- a/src/shared/styles/theme.ts
+++ b/src/shared/styles/theme.ts
@@ -591,6 +591,55 @@ export const theme = createTheme({
},
},
},
+ MuiAlert: {
+ styleOverrides: {
+ root: ({ ownerState: { variant, severity } }) => ({
+ fontSize: variables.font.size.lg,
+ lineHeight: variables.font.lineHeight.lg,
+ letterSpacing: variables.font.letterSpacing.md,
+ color: variables.palette.on_surface,
+ padding: theme.spacing(1.2, 1.6),
+ borderRadius: 0,
+ alignItems: 'center',
+ ...(variant === 'standard' && {
+ ...(severity === 'info' && {
+ backgroundColor: variables.palette.blue_alfa30,
+ }),
+ ...(severity === 'success' && {
+ backgroundColor: variables.palette.green_alfa30,
+ }),
+ ...(severity === 'warning' && {
+ backgroundColor: variables.palette.yellow_alfa30,
+ }),
+ ...(severity === 'error' && {
+ backgroundColor: variables.palette.error_container,
+ }),
+ }),
+ '.MuiAlert-action': {
+ marginRight: 0,
+ paddingTop: 0,
+ alignItems: 'center',
+ },
+ '.MuiAlert-icon': {
+ marginLeft: 'auto',
+ },
+ '.MuiAlert-message': {
+ padding: 0,
+ maxWidth: theme.spacing(80),
+ },
+ '.MuiLink-root:hover': {
+ textDecorationColor: 'transparent',
+ },
+ '.MuiButton-root': {
+ padding: theme.spacing(1),
+ margin: theme.spacing(0.4),
+ },
+ '.MuiButton-text:hover': {
+ backgroundColor: variables.palette.on_surface_alfa8,
+ },
+ }),
+ },
+ },
},
palette: {
background: {
@@ -599,6 +648,15 @@ export const theme = createTheme({
primary: {
main: variables.palette.primary,
},
+ info: {
+ main: variables.palette.blue,
+ },
+ success: {
+ main: variables.palette.green,
+ },
+ warning: {
+ main: variables.palette.yellow,
+ },
error: {
main: variables.palette.semantic.error,
},
diff --git a/src/svgSprite.ts b/src/svgSprite.ts
index bf5c1a9ca0..bce5450ed9 100644
--- a/src/svgSprite.ts
+++ b/src/svgSprite.ts
@@ -10,7 +10,7 @@ export const svgSprite = (): string => `
-
+
@@ -74,7 +74,7 @@ export const svgSprite = (): string => `
/>
+ />
@@ -104,7 +104,7 @@ export const svgSprite = (): string => `
/>
-
+
@@ -218,18 +218,18 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
@@ -247,7 +247,7 @@ export const svgSprite = (): string => `
-
+
@@ -255,9 +255,9 @@ export const svgSprite = (): string => `
-
+
-
@@ -274,23 +274,23 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
+
-
+
@@ -326,23 +326,23 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
-
+
-
@@ -381,19 +381,23 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
+
+
+
+
@@ -429,15 +433,15 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
@@ -468,19 +472,19 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
+
@@ -509,7 +513,7 @@ export const svgSprite = (): string => `
-
+
@@ -560,11 +564,11 @@ export const svgSprite = (): string => `
-
+
-
+
@@ -582,7 +586,7 @@ export const svgSprite = (): string => `
-
+
@@ -603,33 +607,33 @@ export const svgSprite = (): string => `
-
+
-
-
+
-
+
-
+
-
+
-
+
@@ -641,7 +645,7 @@ export const svgSprite = (): string => `
-
+
@@ -653,7 +657,7 @@ export const svgSprite = (): string => `
-
+
@@ -665,43 +669,43 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -711,7 +715,7 @@ export const svgSprite = (): string => `
-
+
@@ -744,7 +748,7 @@ export const svgSprite = (): string => `
-
+
@@ -775,7 +779,7 @@ export const svgSprite = (): string => `
-
+
@@ -806,33 +810,33 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -840,53 +844,53 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
+
+
-
+
-
+
-
+
@@ -913,23 +917,23 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
+
-
+
@@ -937,29 +941,29 @@ export const svgSprite = (): string => `
-
+
-
+
-
+
-
+
-
+
-
+
@@ -967,12 +971,12 @@ export const svgSprite = (): string => `
-
+
-
+
@@ -999,7 +1003,7 @@ export const svgSprite = (): string => `
-
+
@@ -1032,11 +1036,15 @@ export const svgSprite = (): string => `
-
-
+
+
-
+
+
+
+
+