diff --git a/packages/web/package.json b/packages/web/package.json index bb4bfcea..1a5fc43b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -21,6 +21,7 @@ "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "clipboard-copy": "^4.0.1", + "compare-versions": "^4.1.3", "graphql": "^15.6.0", "lodash": "^4.17.21", "luxon": "^2.3.1", diff --git a/packages/web/src/components/Drawer/index.tsx b/packages/web/src/components/Drawer/index.tsx index 2e6daf5b..3728c96a 100644 --- a/packages/web/src/components/Drawer/index.tsx +++ b/packages/web/src/components/Drawer/index.tsx @@ -5,6 +5,7 @@ import Toolbar from '@mui/material/Toolbar'; import List from '@mui/material/List'; import Divider from '@mui/material/Divider'; import useMediaQuery from '@mui/material/useMediaQuery'; +import Badge from '@mui/material/Badge'; import ListItemLink from 'components/ListItemLink'; import HideOnScroll from 'components/HideOnScroll'; @@ -17,6 +18,7 @@ type DrawerLink = { Icon: React.ElementType; primary: string; to: string; + badgeContent?: React.ReactNode; }; type DrawerProps = { @@ -65,10 +67,14 @@ export default function Drawer(props: DrawerProps): React.ReactElement { - {bottomLinks.map(({ Icon, primary, to }, index) => ( + {bottomLinks.map(({ Icon, badgeContent, primary, to }, index) => ( } + icon={( + + + + )} primary={formatMessage(primary)} to={to} onClick={closeOnClick} diff --git a/packages/web/src/components/Layout/index.tsx b/packages/web/src/components/Layout/index.tsx index 43fb2500..a04271e5 100644 --- a/packages/web/src/components/Layout/index.tsx +++ b/packages/web/src/components/Layout/index.tsx @@ -9,6 +9,7 @@ import HistoryIcon from '@mui/icons-material/History'; import NotificationsIcon from '@mui/icons-material/Notifications'; import * as URLS from 'config/urls'; +import useVersion from 'hooks/useVersion'; import AppBar from 'components/AppBar'; import Drawer from 'components/Drawer'; @@ -34,15 +35,17 @@ const drawerLinks = [ }, ]; -const drawerBottomLinks = [ +const generateDrawerBottomLinks = ({ notificationBadgeContent = 0 }) => [ { Icon: NotificationsIcon, primary: 'settingsDrawer.notifications', to: URLS.UPDATES, + badgeContent: notificationBadgeContent, }, ] export default function PublicLayout({ children }: PublicLayoutProps): React.ReactElement { + const version = useVersion(); const theme = useTheme(); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { noSsr: true }); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); @@ -50,6 +53,10 @@ export default function PublicLayout({ children }: PublicLayoutProps): React.Rea const openDrawer = () => setDrawerOpen(true); const closeDrawer = () => setDrawerOpen(false); + const drawerBottomLinks = generateDrawerBottomLinks({ + notificationBadgeContent: version.newVersionCount, + }); + return ( <> diff --git a/packages/web/src/graphql/queries/healthcheck.ts b/packages/web/src/graphql/queries/healthcheck.ts new file mode 100644 index 00000000..2fa4536c --- /dev/null +++ b/packages/web/src/graphql/queries/healthcheck.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const HEALTHCHECK = gql` + query Healthcheck { + healthcheck { + version + } + } +`; diff --git a/packages/web/src/hooks/useNotifications.ts b/packages/web/src/hooks/useNotifications.ts new file mode 100644 index 00000000..7f0a396b --- /dev/null +++ b/packages/web/src/hooks/useNotifications.ts @@ -0,0 +1,26 @@ +import * as React from 'react'; +import appConfig from 'config/app'; + +interface INotification { + name: string; + createdAt: string; + documentationUrl: string; + description: string; +} + +export default function useNotifications(): INotification[] { + const [notifications, setNotifications] = React.useState([]); + + React.useEffect(() => { + fetch(`${appConfig.notificationsUrl}/notifications.json`) + .then((response) => response.json()) + .then((notifications) => { + if (Array.isArray(notifications) && notifications.length) { + setNotifications(notifications); + } + }) + .catch(console.error); + }, []); + + return notifications; +} diff --git a/packages/web/src/hooks/useVersion.ts b/packages/web/src/hooks/useVersion.ts new file mode 100644 index 00000000..d3302daa --- /dev/null +++ b/packages/web/src/hooks/useVersion.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@apollo/client'; +import { compare } from 'compare-versions'; + + +import { HEALTHCHECK } from 'graphql/queries/healthcheck'; +import useNotifications from 'hooks/useNotifications'; + +type TVersionInfo = { + version: string; + newVersionCount: number; +} + +export default function useVersion(): TVersionInfo { + const notifications = useNotifications(); + const { data } = useQuery(HEALTHCHECK, { fetchPolicy: 'cache-and-network' }); + const version = data?.healthcheck.version; + + const newVersionCount = notifications.reduce((count, notification) => { + if (!version) return 0; + + // an unexpectedly invalid version would throw and thus, try-catch. + try { + const isNewer = compare(version, notification.name, '<'); + + return isNewer ? count + 1 : count; + } catch { + return count; + } + }, 0); + + return { + version, + newVersionCount, + }; +} diff --git a/packages/web/src/pages/Notifications/index.tsx b/packages/web/src/pages/Notifications/index.tsx index d451681b..80bd5301 100644 --- a/packages/web/src/pages/Notifications/index.tsx +++ b/packages/web/src/pages/Notifications/index.tsx @@ -2,13 +2,13 @@ import * as React from 'react'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; -import appConfig from 'config/app'; +import useNotifications from 'hooks/useNotifications'; import Container from 'components/Container'; import NotificationCard from 'components/NotificationCard'; import PageTitle from 'components/PageTitle'; import useFormatMessage from 'hooks/useFormatMessage'; -interface IUpdate { +interface INotification { name: string; createdAt: string; documentationUrl: string; @@ -17,18 +17,7 @@ interface IUpdate { export default function Updates(): React.ReactElement { const formatMessage = useFormatMessage(); - const [updates, setUpdates] = React.useState([]); - - React.useEffect(() => { - fetch(`${appConfig.notificationsUrl}/notifications.json`) - .then((response) => response.json()) - .then((updates) => { - if (Array.isArray(updates) && updates.length) { - setUpdates(updates); - } - }) - .catch(console.error); - }, []); + const notifications = useNotifications(); return ( @@ -40,13 +29,13 @@ export default function Updates(): React.ReactElement { - {updates.map((update: IUpdate) => ( + {notifications.map((notification: INotification) => ( ))} diff --git a/yarn.lock b/yarn.lock index 977ec379..6dea5066 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7184,6 +7184,11 @@ compare-func@^2.0.0: array-ify "^1.0.0" dot-prop "^5.1.0" +compare-versions@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-4.1.3.tgz#8f7b8966aef7dc4282b45dfa6be98434fc18a1a4" + integrity sha512-WQfnbDcrYnGr55UwbxKiQKASnTtNnaAWVi8jZyy8NTpVAXWACSne8lMD1iaIo9AiU6mnuLvSVshCzewVuWxHUg== + component-emitter@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"