feat: highlight newer versions in notifications
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"@types/react": "^17.0.0",
|
"@types/react": "^17.0.0",
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^17.0.0",
|
||||||
"clipboard-copy": "^4.0.1",
|
"clipboard-copy": "^4.0.1",
|
||||||
|
"compare-versions": "^4.1.3",
|
||||||
"graphql": "^15.6.0",
|
"graphql": "^15.6.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^2.3.1",
|
"luxon": "^2.3.1",
|
||||||
|
@@ -5,6 +5,7 @@ import Toolbar from '@mui/material/Toolbar';
|
|||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import Badge from '@mui/material/Badge';
|
||||||
|
|
||||||
import ListItemLink from 'components/ListItemLink';
|
import ListItemLink from 'components/ListItemLink';
|
||||||
import HideOnScroll from 'components/HideOnScroll';
|
import HideOnScroll from 'components/HideOnScroll';
|
||||||
@@ -17,6 +18,7 @@ type DrawerLink = {
|
|||||||
Icon: React.ElementType;
|
Icon: React.ElementType;
|
||||||
primary: string;
|
primary: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
badgeContent?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DrawerProps = {
|
type DrawerProps = {
|
||||||
@@ -65,10 +67,14 @@ export default function Drawer(props: DrawerProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<List sx={{ py: 0, mt: 3 }}>
|
<List sx={{ py: 0, mt: 3 }}>
|
||||||
{bottomLinks.map(({ Icon, primary, to }, index) => (
|
{bottomLinks.map(({ Icon, badgeContent, primary, to }, index) => (
|
||||||
<ListItemLink
|
<ListItemLink
|
||||||
key={`${to}-${index}`}
|
key={`${to}-${index}`}
|
||||||
icon={<Icon htmlColor={theme.palette.primary.main} />}
|
icon={(
|
||||||
|
<Badge badgeContent={badgeContent} color="secondary" max={99}>
|
||||||
|
<Icon htmlColor={theme.palette.primary.main} />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
primary={formatMessage(primary)}
|
primary={formatMessage(primary)}
|
||||||
to={to}
|
to={to}
|
||||||
onClick={closeOnClick}
|
onClick={closeOnClick}
|
||||||
|
@@ -9,6 +9,7 @@ import HistoryIcon from '@mui/icons-material/History';
|
|||||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||||
|
|
||||||
import * as URLS from 'config/urls';
|
import * as URLS from 'config/urls';
|
||||||
|
import useVersion from 'hooks/useVersion';
|
||||||
import AppBar from 'components/AppBar';
|
import AppBar from 'components/AppBar';
|
||||||
import Drawer from 'components/Drawer';
|
import Drawer from 'components/Drawer';
|
||||||
|
|
||||||
@@ -34,15 +35,17 @@ const drawerLinks = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const drawerBottomLinks = [
|
const generateDrawerBottomLinks = ({ notificationBadgeContent = 0 }) => [
|
||||||
{
|
{
|
||||||
Icon: NotificationsIcon,
|
Icon: NotificationsIcon,
|
||||||
primary: 'settingsDrawer.notifications',
|
primary: 'settingsDrawer.notifications',
|
||||||
to: URLS.UPDATES,
|
to: URLS.UPDATES,
|
||||||
|
badgeContent: notificationBadgeContent,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function PublicLayout({ children }: PublicLayoutProps): React.ReactElement {
|
export default function PublicLayout({ children }: PublicLayoutProps): React.ReactElement {
|
||||||
|
const version = useVersion();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { noSsr: true });
|
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { noSsr: true });
|
||||||
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
|
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
|
||||||
@@ -50,6 +53,10 @@ export default function PublicLayout({ children }: PublicLayoutProps): React.Rea
|
|||||||
const openDrawer = () => setDrawerOpen(true);
|
const openDrawer = () => setDrawerOpen(true);
|
||||||
const closeDrawer = () => setDrawerOpen(false);
|
const closeDrawer = () => setDrawerOpen(false);
|
||||||
|
|
||||||
|
const drawerBottomLinks = generateDrawerBottomLinks({
|
||||||
|
notificationBadgeContent: version.newVersionCount,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBar drawerOpen={isDrawerOpen} onDrawerOpen={openDrawer} onDrawerClose={closeDrawer} />
|
<AppBar drawerOpen={isDrawerOpen} onDrawerOpen={openDrawer} onDrawerClose={closeDrawer} />
|
||||||
|
9
packages/web/src/graphql/queries/healthcheck.ts
Normal file
9
packages/web/src/graphql/queries/healthcheck.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const HEALTHCHECK = gql`
|
||||||
|
query Healthcheck {
|
||||||
|
healthcheck {
|
||||||
|
version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
26
packages/web/src/hooks/useNotifications.ts
Normal file
26
packages/web/src/hooks/useNotifications.ts
Normal file
@@ -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<INotification[]>([]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
35
packages/web/src/hooks/useVersion.ts
Normal file
35
packages/web/src/hooks/useVersion.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
@@ -2,13 +2,13 @@ import * as React from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
|
|
||||||
import appConfig from 'config/app';
|
import useNotifications from 'hooks/useNotifications';
|
||||||
import Container from 'components/Container';
|
import Container from 'components/Container';
|
||||||
import NotificationCard from 'components/NotificationCard';
|
import NotificationCard from 'components/NotificationCard';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import useFormatMessage from 'hooks/useFormatMessage';
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
|
||||||
interface IUpdate {
|
interface INotification {
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
documentationUrl: string;
|
documentationUrl: string;
|
||||||
@@ -17,18 +17,7 @@ interface IUpdate {
|
|||||||
|
|
||||||
export default function Updates(): React.ReactElement {
|
export default function Updates(): React.ReactElement {
|
||||||
const formatMessage = useFormatMessage();
|
const formatMessage = useFormatMessage();
|
||||||
const [updates, setUpdates] = React.useState<IUpdate[]>([]);
|
const notifications = useNotifications();
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
fetch(`${appConfig.notificationsUrl}/notifications.json`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((updates) => {
|
|
||||||
if (Array.isArray(updates) && updates.length) {
|
|
||||||
setUpdates(updates);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ py: 3 }}>
|
<Box sx={{ py: 3 }}>
|
||||||
@@ -40,13 +29,13 @@ export default function Updates(): React.ReactElement {
|
|||||||
<Stack
|
<Stack
|
||||||
gap={2}
|
gap={2}
|
||||||
>
|
>
|
||||||
{updates.map((update: IUpdate) => (
|
{notifications.map((notification: INotification) => (
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
key={update.name}
|
key={notification.name}
|
||||||
name={`Version ${update.name}`}
|
name={`Version ${notification.name}`}
|
||||||
description={update.description}
|
description={notification.description}
|
||||||
createdAt={update.createdAt}
|
createdAt={notification.createdAt}
|
||||||
documentationUrl={update.documentationUrl}
|
documentationUrl={notification.documentationUrl}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@@ -7184,6 +7184,11 @@ compare-func@^2.0.0:
|
|||||||
array-ify "^1.0.0"
|
array-ify "^1.0.0"
|
||||||
dot-prop "^5.1.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:
|
component-emitter@^1.2.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
|
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
|
||||||
|
Reference in New Issue
Block a user