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!<1>End users must update to the new app.<2>Take these steps now to ensure participant response data is not lost.", + "versionWarningBanner": "<0>You are using the new version of MindLogger! <1>End users must update to the new app. <2>Take these steps now to ensure participant response data is not lost.", "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!<1>Les utilisateurs finaux doivent mettre à jour vers la nouvelle application.<2>Prenez ces mesures dès maintenant pour vous assurer que les données de réponse des participants ne sont pas perdues.", + "versionWarningBanner": "<0>Vous utilisez la nouvelle version de MindLogger! <1>Les utilisateurs finaux doivent mettre à jour vers la nouvelle application. <2>Prenez ces mesures dès maintenant pour vous assurer que les données de réponse des participants ne sont pas perdues.", "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 => ` - + - + - + - + @@ -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 => ` - - + + - + + + + +