From 004dcd04d51affd09327b8198052faefb2cb98a3 Mon Sep 17 00:00:00 2001 From: Oliver <oliver.henry.walters@gmail.com> Date: Thu, 14 Sep 2023 23:44:35 +1000 Subject: [PATCH] [PUI]: Basic notifications page (#5537) * Add notification indicator to the navbar * Fetch every 30 seconds * Basic notifications drawer * Simple page for rendering notification tables * Implement notification table actions * Move table component --- src/frontend/src/components/nav/Header.tsx | 55 ++++++++- src/frontend/src/components/nav/MainMenu.tsx | 4 - .../src/components/nav/NotificationDrawer.tsx | 111 ++++++++++++++++++ .../notifications/NotificationsTable.tsx | 52 ++++++++ src/frontend/src/pages/Notifications.tsx | 92 +++++++++++++++ src/frontend/src/router.tsx | 9 ++ 6 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/components/nav/NotificationDrawer.tsx create mode 100644 src/frontend/src/components/tables/notifications/NotificationsTable.tsx create mode 100644 src/frontend/src/pages/Notifications.tsx diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index 6b403db455..15edb27a71 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -1,14 +1,18 @@ -import { ActionIcon, Container, Group, Tabs } from '@mantine/core'; +import { ActionIcon, Container, Group, Indicator, Tabs } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { IconSearch } from '@tabler/icons-react'; +import { IconBell, IconSearch } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { api } from '../../App'; import { navTabs as mainNavTabs } from '../../defaults/links'; import { InvenTreeStyle } from '../../globalStyle'; import { ScanButton } from '../items/ScanButton'; import { MainMenu } from './MainMenu'; import { NavHoverMenu } from './NavHoverMenu'; import { NavigationDrawer } from './NavigationDrawer'; +import { NotificationDrawer } from './NotificationDrawer'; import { SearchDrawer } from './SearchDrawer'; export function Header() { @@ -20,10 +24,46 @@ export function Header() { { open: openSearchDrawer, close: closeSearchDrawer } ] = useDisclosure(false); + const [ + notificationDrawerOpened, + { open: openNotificationDrawer, close: closeNotificationDrawer } + ] = useDisclosure(false); + + const [notificationCount, setNotificationCount] = useState<number>(0); + + // Fetch number of notifications for the current user + const notifications = useQuery({ + queryKey: ['notification-count'], + queryFn: async () => { + return api + .get('/notifications/', { + params: { + read: false, + limit: 1 + } + }) + .then((response) => { + setNotificationCount(response.data.count); + return response.data; + }) + .catch((error) => { + console.error('Error fetching notifications:', error); + return error; + }); + }, + refetchInterval: 30000, + refetchOnMount: true, + refetchOnWindowFocus: false + }); + return ( <div className={classes.layoutHeader}> <SearchDrawer opened={searchDrawerOpened} onClose={closeSearchDrawer} /> <NavigationDrawer opened={navDrawerOpened} close={closeNavDrawer} /> + <NotificationDrawer + opened={notificationDrawerOpened} + onClose={closeNotificationDrawer} + /> <Container className={classes.layoutHeaderSection} size={'xl'}> <Group position="apart"> <Group> @@ -35,6 +75,17 @@ export function Header() { <ActionIcon onClick={openSearchDrawer}> <IconSearch /> </ActionIcon> + <ActionIcon onClick={openNotificationDrawer}> + <Indicator + radius="lg" + size="18" + label={notificationCount} + color="red" + disabled={notificationCount <= 0} + > + <IconBell /> + </Indicator> + </ActionIcon> <MainMenu /> </Group> </Group> diff --git a/src/frontend/src/components/nav/MainMenu.tsx b/src/frontend/src/components/nav/MainMenu.tsx index d116b32a46..1bfd080cc7 100644 --- a/src/frontend/src/components/nav/MainMenu.tsx +++ b/src/frontend/src/components/nav/MainMenu.tsx @@ -34,10 +34,6 @@ export function MainMenu() { </UnstyledButton> </Menu.Target> <Menu.Dropdown> - <Menu.Item icon={<IconHeart />}> - <Trans>Notifications</Trans> - <PlaceholderPill /> - </Menu.Item> <Menu.Item icon={<IconUserCircle />}> <Trans>Profile</Trans> <PlaceholderPill /> </Menu.Item> diff --git a/src/frontend/src/components/nav/NotificationDrawer.tsx b/src/frontend/src/components/nav/NotificationDrawer.tsx new file mode 100644 index 0000000000..f2e0391047 --- /dev/null +++ b/src/frontend/src/components/nav/NotificationDrawer.tsx @@ -0,0 +1,111 @@ +import { t } from '@lingui/macro'; +import { + ActionIcon, + Divider, + Drawer, + LoadingOverlay, + Space, + Tooltip +} from '@mantine/core'; +import { Badge, Group, Stack, Text } from '@mantine/core'; +import { IconBellCheck, IconBellPlus, IconBookmark } from '@tabler/icons-react'; +import { IconMacro } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { api } from '../../App'; + +/** + * Construct a notification drawer. + */ +export function NotificationDrawer({ + opened, + onClose +}: { + opened: boolean; + onClose: () => void; +}) { + const navigate = useNavigate(); + + const notificationQuery = useQuery({ + enabled: opened, + queryKey: ['notifications', opened], + queryFn: async () => + api + .get('/notifications/', { + params: { + read: false, + limit: 10 + } + }) + .then((response) => response.data) + .catch((error) => { + console.error('Error fetching notifications:', error); + return error; + }), + refetchOnMount: false, + refetchOnWindowFocus: false + }); + + return ( + <Drawer + opened={opened} + size="md" + position="right" + onClose={onClose} + withCloseButton={false} + styles={{ + header: { + width: '100%' + }, + title: { + width: '100%' + } + }} + title={ + <Group position="apart" noWrap={true}> + <Text size="lg">{t`Notifications`}</Text> + <ActionIcon + onClick={() => { + onClose(); + navigate('/notifications/'); + }} + > + <IconBellPlus /> + </ActionIcon> + </Group> + } + > + <Stack spacing="xs"> + <Divider /> + <LoadingOverlay visible={notificationQuery.isFetching} /> + {notificationQuery.data?.results.map((notification: any) => ( + <Group position="apart"> + <Stack spacing="3"> + <Text size="sm">{notification.target?.name ?? 'target'}</Text> + <Text size="xs">{notification.age_human ?? 'name'}</Text> + </Stack> + <Space /> + <ActionIcon + color="gray" + variant="hover" + onClick={() => { + api + .patch(`/notifications/${notification.pk}/`, { + read: true + }) + .then((response) => { + notificationQuery.refetch(); + }); + }} + > + <Tooltip label={t`Mark as read`}> + <IconBellCheck /> + </Tooltip> + </ActionIcon> + </Group> + ))} + </Stack> + </Drawer> + ); +} diff --git a/src/frontend/src/components/tables/notifications/NotificationsTable.tsx b/src/frontend/src/components/tables/notifications/NotificationsTable.tsx new file mode 100644 index 0000000000..08212a6363 --- /dev/null +++ b/src/frontend/src/components/tables/notifications/NotificationsTable.tsx @@ -0,0 +1,52 @@ +import { t } from '@lingui/macro'; +import { useMemo } from 'react'; + +import { TableColumn } from '../Column'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowAction } from '../RowActions'; + +export function NotificationTable({ + params, + refreshId, + tableKey, + actions +}: { + params: any; + refreshId: string; + tableKey: string; + actions: (record: any) => RowAction[]; +}) { + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'age_human', + title: t`Age`, + sortable: true + }, + { + accessor: 'category', + title: t`Category`, + sortable: true + }, + { + accessor: `name`, + title: t`Notification` + }, + { + accessor: 'message', + title: t`Message` + } + ]; + }, []); + + return ( + <InvenTreeTable + url="/notifications/" + tableKey={tableKey} + refreshId={refreshId} + params={params} + rowActions={actions} + columns={columns} + /> + ); +} diff --git a/src/frontend/src/pages/Notifications.tsx b/src/frontend/src/pages/Notifications.tsx new file mode 100644 index 0000000000..e6a8b40f2c --- /dev/null +++ b/src/frontend/src/pages/Notifications.tsx @@ -0,0 +1,92 @@ +import { t } from '@lingui/macro'; +import { Stack } from '@mantine/core'; +import { IconBellCheck, IconBellExclamation } from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { api } from '../App'; +import { StylishText } from '../components/items/StylishText'; +import { PanelGroup } from '../components/nav/PanelGroup'; +import { NotificationTable } from '../components/tables/notifications/NotificationsTable'; +import { useTableRefresh } from '../hooks/TableRefresh'; + +export default function NotificationsPage() { + const unreadRefresh = useTableRefresh(); + const historyRefresh = useTableRefresh(); + + const notificationPanels = useMemo(() => { + return [ + { + name: 'notifications-unread', + label: t`Notifications`, + icon: <IconBellExclamation size="18" />, + content: ( + <NotificationTable + params={{ read: false }} + refreshId={unreadRefresh.refreshId} + tableKey="notifications-unread" + actions={(record) => [ + { + title: t`Mark as read`, + onClick: () => { + api + .patch(`/notifications/${record.pk}/`, { + read: true + }) + .then((response) => { + unreadRefresh.refreshTable(); + }); + } + } + ]} + /> + ) + }, + { + name: 'notifications-history', + label: t`History`, + icon: <IconBellCheck size="18" />, + content: ( + <NotificationTable + params={{ read: true }} + refreshId={historyRefresh.refreshId} + tableKey="notifications-history" + actions={(record) => [ + { + title: t`Mark as unread`, + onClick: () => { + api + .patch(`/notifications/${record.pk}/`, { + read: false + }) + .then((response) => { + historyRefresh.refreshTable(); + }); + } + }, + { + title: t`Delete`, + color: 'red', + onClick: () => { + api + .delete(`/notifications/${record.pk}/`) + .then((response) => { + historyRefresh.refreshTable(); + }); + } + } + ]} + /> + ) + } + ]; + }, [historyRefresh, unreadRefresh]); + + return ( + <> + <Stack spacing="xs"> + <StylishText>{t`Notifications`}</StylishText> + <PanelGroup panels={notificationPanels} /> + </Stack> + </> + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 2a757ad618..10c8fbc5d7 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -19,6 +19,11 @@ export const Dashboard = Loadable( lazy(() => import('./pages/Index/Dashboard')) ); export const ErrorPage = Loadable(lazy(() => import('./pages/ErrorPage'))); + +export const Notifications = Loadable( + lazy(() => import('./pages/Notifications')) +); + export const Profile = Loadable( lazy(() => import('./pages/Index/Profile/Profile')) ); @@ -60,6 +65,10 @@ export const router = createBrowserRouter( path: 'dashboard/', element: <Dashboard /> }, + { + path: 'notifications/', + element: <Notifications /> + }, { path: 'playground/', element: <Playground />