From 612b821bfa740b834204df415b8920ac9b6f9237 Mon Sep 17 00:00:00 2001 From: farmerpaul Date: Thu, 1 Feb 2024 11:46:09 -0400 Subject: [PATCH 01/10] M2-4969: add system banners slice Currently the app only shows a single static banner below the breadcrumbs. This slice will support being able to display other banners in place of popups. --- src/redux/store/reducers.ts | 2 ++ src/shared/state/Banners/Banners.reducer.ts | 15 +++++++++++++++ src/shared/state/Banners/Banners.schema.ts | 13 +++++++++++++ src/shared/state/Banners/Banners.state.tsx | 7 +++++++ src/shared/state/Banners/index.ts | 21 +++++++++++++++++++++ 5 files changed, 58 insertions(+) create mode 100644 src/shared/state/Banners/Banners.reducer.ts create mode 100644 src/shared/state/Banners/Banners.schema.ts create mode 100644 src/shared/state/Banners/Banners.state.tsx create mode 100644 src/shared/state/Banners/index.ts 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/shared/state/Banners/Banners.reducer.ts b/src/shared/state/Banners/Banners.reducer.ts new file mode 100644 index 0000000000..ffd2a4c058 --- /dev/null +++ b/src/shared/state/Banners/Banners.reducer.ts @@ -0,0 +1,15 @@ +import { PayloadAction } from '@reduxjs/toolkit'; + +import { BannerPayload, BannerSchema } from './Banners.schema'; + +export const reducers = { + addBanner: (state: BannerSchema, { payload }: PayloadAction): void => { + state.data.banners.push(payload); + }, + removeBanner: (state: BannerSchema, { payload }: PayloadAction): void => { + state.data.banners = state.data.banners.filter(({ key }) => key !== payload.key); + }, + removeAllBanners: (state: BannerSchema): 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..6e605a52cc --- /dev/null +++ b/src/shared/state/Banners/Banners.schema.ts @@ -0,0 +1,13 @@ +export const BannerComponents = { + // TODO: add available banner components here +}; + +export type BannerPayload = { + key: keyof typeof BannerComponents; +}; + +export type BannerSchema = { + 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..5e37e4dbc6 --- /dev/null +++ b/src/shared/state/Banners/Banners.state.tsx @@ -0,0 +1,7 @@ +import { BannerSchema } from './Banners.schema'; + +export const state: BannerSchema = { + data: { + banners: [], + }, +}; diff --git a/src/shared/state/Banners/index.ts b/src/shared/state/Banners/index.ts new file mode 100644 index 0000000000..19f263d4c0 --- /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 { BannerSchema } from './Banners.schema'; + +export * from './Banners.schema'; + +const slice = createSlice({ + name: 'banners', + initialState, + reducers, +}); + +export const banners = { + slice, + actions: slice.actions, + useData: (): BannerSchema['data'] => useAppSelector(({ banners: { data } }) => data), +}; From b40821b1baf9b49a86e9944db69afe946b814b97 Mon Sep 17 00:00:00 2001 From: farmerpaul Date: Fri, 2 Feb 2024 12:38:17 -0400 Subject: [PATCH 02/10] M2-4969: add generic Banner component + tests Used the MUI Alert component, which already closely aligns with Figma designs. Added MUI Snackbar-like a11y behaviour to prevent alerts from auto-closing while hovering or if window missing focus. Set up default styling via MUI theme settings. Added missing sprites for success/error states. --- package-lock.json | 18 ++ package.json | 1 + .../components/Banners/Banner/Banner.test.tsx | 57 +++++ .../components/Banners/Banner/Banner.tsx | 59 ++++++ .../components/Banners/Banner/Banner.types.ts | 9 + src/shared/components/Banners/Banner/index.ts | 2 + src/shared/styles/theme.ts | 58 +++++ src/svgSprite.ts | 198 +++++++++--------- 8 files changed, 307 insertions(+), 95 deletions(-) create mode 100644 src/shared/components/Banners/Banner/Banner.test.tsx create mode 100644 src/shared/components/Banners/Banner/Banner.tsx create mode 100644 src/shared/components/Banners/Banner/Banner.types.ts create mode 100644 src/shared/components/Banners/Banner/index.ts diff --git a/package-lock.json b/package-lock.json index 5ecc4e306e..42483f748c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "react-secure-storage": "^1.3.0", "stream-browserify": "^3.0.0", "typescript": "^4.9.5", + "use-window-focus": "^1.4.2", "uuid": "^9.0.1", "web-vitals": "^3.5.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.0/xlsx-0.20.0.tgz", @@ -24389,6 +24390,17 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/use-window-focus": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/use-window-focus/-/use-window-focus-1.4.2.tgz", + "integrity": "sha512-6aLzUtgcph92IoT+ZmEPpkdLW3O5frL7sNIpbt/2pBh6HwHZTRagsIxILrLTfrguzEDQqy5FM76K4wqIj4V6mw==", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -43178,6 +43190,12 @@ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", "requires": {} }, + "use-window-focus": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/use-window-focus/-/use-window-focus-1.4.2.tgz", + "integrity": "sha512-6aLzUtgcph92IoT+ZmEPpkdLW3O5frL7sNIpbt/2pBh6HwHZTRagsIxILrLTfrguzEDQqy5FM76K4wqIj4V6mw==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 502bd89928..f853ebc142 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "react-secure-storage": "^1.3.0", "stream-browserify": "^3.0.0", "typescript": "^4.9.5", + "use-window-focus": "^1.4.2", "uuid": "^9.0.1", "web-vitals": "^3.5.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.0/xlsx-0.20.0.tgz", 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..929371612a --- /dev/null +++ b/src/shared/components/Banners/Banner/Banner.test.tsx @@ -0,0 +1,57 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import useWindowFocus from 'use-window-focus'; + +import { Banner } from './Banner'; + +const mockOnClose = jest.fn(); + +const props = { + children: 'Test banner', + onClose: mockOnClose, + duration: 5000, +}; + +jest.mock('use-window-focus'); + +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(); + (useWindowFocus as jest.Mock).mockReturnValue(true); + + render(); + + jest.advanceTimersByTime(props.duration); + expect(mockOnClose).toHaveBeenCalled(); + }); + + test('banner does not auto-close if window unfocused', () => { + jest.useFakeTimers(); + (useWindowFocus as jest.Mock).mockReturnValue(false); + + render(); + + jest.advanceTimersByTime(props.duration); + 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..7a8446ebd8 --- /dev/null +++ b/src/shared/components/Banners/Banner/Banner.tsx @@ -0,0 +1,59 @@ +import { Alert } from '@mui/material'; +import { useEffect, useState } from 'react'; +import useWindowFocus from 'use-window-focus'; + +import { Svg } from 'shared/components/Svg'; +import { StyledClearedButton } from 'shared/styles'; + +import { BannerProps } from './Banner.types'; + +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} + + ); +}; + +const getSvg = (id: string) => ; 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/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 => ` - - + + - + + + + +