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 />