From 56a47c304cd1ce866eee81779509ada4d0707f9d Mon Sep 17 00:00:00 2001
From: Sampo Tawast <5328394+sirtawast@users.noreply.github.com>
Date: Mon, 26 Aug 2024 09:51:57 +0300
Subject: [PATCH] feat: add unread messages notifier into header (hl-1410)
(#3214)
* feat(shared): add classname to Header so it can be extended with StyledComponents
* feat(shared): add media queries per pixel
* feat(handler): add new messages notifier
* feat(handler): add backend api endpoint to fetch applications with messages
* refactor: rename endpoint
---
.../applications/api/v1/application_views.py | 13 ++
.../tests/test_applications_api.py | 27 +++
.../src/components/header/Header.sc.ts | 181 ++++++++++++++++++
.../handler/src/components/header/Header.tsx | 9 +-
.../src/components/header/HeaderNotifier.tsx | 63 ++++++
.../hooks/useApplicationsWithMessagesQuery.ts | 42 ++++
.../hooks/useMarkLastMessageUnreadQuery.ts | 9 +-
.../src/hooks/useMarkMessagesReadQuery.ts | 10 +-
.../shared/src/backend-api/backend-api.ts | 5 +-
.../shared/src/components/header/Header.tsx | 4 +-
frontend/shared/src/styles/mediaQueries.ts | 36 +++-
11 files changed, 384 insertions(+), 15 deletions(-)
create mode 100644 frontend/benefit/handler/src/components/header/Header.sc.ts
create mode 100644 frontend/benefit/handler/src/components/header/HeaderNotifier.tsx
create mode 100644 frontend/benefit/handler/src/hooks/useApplicationsWithMessagesQuery.ts
diff --git a/backend/benefit/applications/api/v1/application_views.py b/backend/benefit/applications/api/v1/application_views.py
index 65b24265b3..78a9283539 100755
--- a/backend/benefit/applications/api/v1/application_views.py
+++ b/backend/benefit/applications/api/v1/application_views.py
@@ -29,6 +29,7 @@
from applications.api.v1.serializers.application import (
ApplicantApplicationSerializer,
+ HandlerApplicationListSerializer,
HandlerApplicationSerializer,
)
from applications.api.v1.serializers.application_alteration import (
@@ -658,6 +659,18 @@ def simplified_application_list(self, request):
status=status.HTTP_200_OK,
)
+ @action(detail=False, methods=["get"])
+ def with_unread_messages(self, request, *args, **kwargs):
+ applications_with_unread_messages = Application.objects.filter(
+ messages__message_type=MessageType.APPLICANT_MESSAGE,
+ messages__seen_by_handler=False,
+ ).distinct()
+ return Response(
+ HandlerApplicationListSerializer(
+ applications_with_unread_messages, many=True
+ ).data,
+ )
+
@action(methods=["GET"], detail=False)
def export_csv(self, request) -> StreamingHttpResponse:
queryset = self.get_queryset()
diff --git a/backend/benefit/applications/tests/test_applications_api.py b/backend/benefit/applications/tests/test_applications_api.py
index 06f1021629..4a5610128b 100755
--- a/backend/benefit/applications/tests/test_applications_api.py
+++ b/backend/benefit/applications/tests/test_applications_api.py
@@ -60,6 +60,8 @@
from messages.automatic_messages import (
get_additional_information_email_notification_subject,
)
+from messages.models import Message, MessageType
+from messages.tests.factories import MessageFactory
from shared.audit_log import models as audit_models
from shared.service_bus.enums import YtjOrganizationCode
from terms.models import TermsOfServiceApproval
@@ -2567,6 +2569,31 @@ def test_application_alterations(api_client, handler_api_client, application):
assert len(response.data["alterations"]) == 3
+def test_applications_with_unread_messages(api_client, handler_api_client, application):
+ response = api_client.get(
+ reverse("v1:handler-application-with-unread-messages"),
+ )
+ assert response.status_code == 403
+
+ assert len(Message.objects.all()) == 0
+ MessageFactory(
+ application=application,
+ message_type=MessageType.APPLICANT_MESSAGE,
+ content="Hello",
+ )
+
+ response = handler_api_client.get(
+ reverse("v1:handler-application-with-unread-messages"),
+ )
+ assert len(response.data) == 1
+ Message.objects.all().update(seen_by_handler=True)
+ response = handler_api_client.get(
+ reverse("v1:handler-application-with-unread-messages"),
+ )
+
+ assert len(response.data) == 0
+
+
def _create_random_applications():
f = faker.Faker()
combos = [
diff --git a/frontend/benefit/handler/src/components/header/Header.sc.ts b/frontend/benefit/handler/src/components/header/Header.sc.ts
new file mode 100644
index 0000000000..61f3ed9e14
--- /dev/null
+++ b/frontend/benefit/handler/src/components/header/Header.sc.ts
@@ -0,0 +1,181 @@
+import Link from 'next/link';
+import BaseHeader from 'shared/components/header/Header';
+import { respondAbovePx } from 'shared/styles/mediaQueries';
+import styled from 'styled-components';
+
+export const $BaseHeader = styled(BaseHeader)`
+ z-index: 99999;
+ background: #1a1a1a;
+`;
+
+export const $ToggleButton = styled.button`
+ all: initial;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ width: 40px;
+ height: 40px;
+ color: white;
+ outline: 0;
+ appearance: none;
+ padding: 0;
+ svg {
+ margin-left: -11px;
+ }
+
+ span {
+ left: 24px;
+ font-size: 14px;
+ position: absolute;
+ pointer-events: none;
+ user-select: none;
+ }
+`;
+
+type $HeaderNotifierProps = {
+ $enabled: boolean;
+};
+export const $HeaderNotifier = styled.div<$HeaderNotifierProps>`
+ position: relative;
+ opacity: ${(props) => (props.$enabled ? 1 : 0.25)};
+ pointer-events: ${(props) => (props.$enabled ? 'auto' : 'none')};
+ ${$ToggleButton} {
+ cursor: pointer;
+ background: ${(props) =>
+ props.$enabled ? props.theme.colors.coatOfArmsDark : 'transparent'};
+
+ &:hover,
+ &:active {
+ background: ${(props) =>
+ props.$enabled ? props.theme.colors.coatOfArms : 'transparent'};
+ }
+
+ &:focus {
+ outline: 2px solid #fff;
+ }
+ }
+`;
+
+type $BoxProps = {
+ $open: boolean;
+};
+
+export const $Box = styled.div<$BoxProps>`
+ position: absolute;
+ top: 50px;
+ z-index: 99999;
+ visibility: ${(props) => (props.$open ? 'visible' : 'hidden')};
+ background: white;
+ color: black;
+ border-radius: 5px;
+ box-shadow: 0 0px 10px rgba(0, 0, 0, 0.4);
+ border: 1px solid #222;
+ width: 420px;
+ left: -300px;
+
+ ${respondAbovePx(992)`
+ left: -200px;
+ `}
+
+ ${respondAbovePx(1460)`
+ left: -100px;
+ `}
+
+ // Triangle
+ &:before {
+ position: absolute;
+ content: '';
+ width: 0px;
+ height: 0px;
+ top: -8px;
+ z-index: 99999;
+ border-style: solid;
+ border-width: 0 6px 8px 6px;
+ border-color: transparent transparent #fff transparent;
+ transform: rotate(0deg);
+ display: none;
+
+ ${respondAbovePx(768)`
+ display: block;
+ left: 313px;
+ `}
+ ${respondAbovePx(992)`
+ left: 213px;
+ `}
+ ${respondAbovePx(1460)`
+ left: 113px;
+ `}
+ }
+
+ h2 {
+ margin: 1rem 1rem 0.75rem;
+ font-size: 1.25rem;
+ color: ${(props) => props.theme.colors.coatOfArms};
+ user-select: none;
+ }
+
+ ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ font-size: 0.95rem;
+
+ li {
+ border-bottom: 1px solid ${(props) => props.theme.colors.black20};
+
+ &:nth-child(even) {
+ background: ${(props) => props.theme.colors.black5};
+ }
+
+ &:first-child {
+ border-top: 1px solid ${(props) => props.theme.colors.black20};
+ }
+
+ &:last-child {
+ border-bottom: 0;
+ border-bottom-left-radius: 5px;
+ border-bottom-right-radius: 5px;
+ }
+ }
+ }
+ &:hover {
+ > ul > li:hover {
+ background: ${(props) => props.theme.colors.black10};
+ }
+ }
+`;
+
+export const $ApplicationWithMessages = styled(Link)`
+ text-decoration: none;
+ color: #222;
+ display: flex;
+ align-items: center;
+ padding: 0.75rem 1rem 0.75rem 1rem;
+ cursor: pointer;
+
+ div {
+ margin-right: 1rem;
+ &:first-child {
+ width: 90px;
+ min-width: 90px;
+ margin-right: 0;
+ }
+ &:nth-child(2) {
+ width: 140px;
+ min-width: 140px;
+ }
+ &:last-child {
+ margin: 0 0 0 auto;
+ width: 20px;
+ height: 24px;
+ }
+ }
+
+ strong {
+ font-weight: 500;
+ }
+
+ box-sizing: border-box;
+`;
diff --git a/frontend/benefit/handler/src/components/header/Header.tsx b/frontend/benefit/handler/src/components/header/Header.tsx
index c614f792f0..43ef468dbc 100644
--- a/frontend/benefit/handler/src/components/header/Header.tsx
+++ b/frontend/benefit/handler/src/components/header/Header.tsx
@@ -6,9 +6,11 @@ import noop from 'lodash/noop';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import * as React from 'react';
-import BaseHeader from 'shared/components/header/Header';
import { getFullName } from 'shared/utils/application.utils';
+import { DefaultTheme } from 'styled-components';
+import { $BaseHeader } from './Header.sc';
+import HeaderNotifier from './HeaderNotifier';
import { useHeader } from './useHeader';
const Header: React.FC = () => {
@@ -33,12 +35,13 @@ const Header: React.FC = () => {
);
return (
-