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 ( - ) : null, + , ]} titleUrl={ROUTES.HOME} skipToContentLabel={t('common:header.linkSkipToContent')} @@ -46,7 +49,7 @@ const Header: React.FC = () => { isNavigationVisible={isNavigationVisible} navigationItems={navigationItems} onLanguageChange={handleLanguageChange} - theme="dark" + theme={'dark' as unknown as DefaultTheme} login={{ isAuthenticated: !isLoginPage && isSuccess, loginLabel: t('common:header.loginLabel'), diff --git a/frontend/benefit/handler/src/components/header/HeaderNotifier.tsx b/frontend/benefit/handler/src/components/header/HeaderNotifier.tsx new file mode 100644 index 0000000000..87c62bc5f4 --- /dev/null +++ b/frontend/benefit/handler/src/components/header/HeaderNotifier.tsx @@ -0,0 +1,63 @@ +import useApplicationMessagesQuery from 'benefit/handler/hooks/useApplicationsWithMessagesQuery'; +import { IconAngleRight, IconBell } from 'hds-react'; +import { useTranslation } from 'next-i18next'; +import * as React from 'react'; + +import { + $ApplicationWithMessages, + $Box, + $HeaderNotifier, + $ToggleButton, +} from './Header.sc'; + +const Header: React.FC = () => { + const [messageCenterActive, setMessageCenterActive] = React.useState(false); + + const { t } = useTranslation(); + const applicationsWithMessages = useApplicationMessagesQuery()?.data || []; + const handleMessageCenterClick = (e: React.MouseEvent): void => { + e.preventDefault(); + setMessageCenterActive(!messageCenterActive); + }; + + return ( + <$HeaderNotifier + $enabled={applicationsWithMessages?.length > 0} + aria-live="polite" + > + {applicationsWithMessages?.length > 0 && ( + <$ToggleButton onClick={(e) => handleMessageCenterClick(e)}> + + {applicationsWithMessages?.length} + + )} + <$Box $open={messageCenterActive} aria-hidden={!messageCenterActive}> +

{t('common:header.messages')}

+
    + {applicationsWithMessages.map((application) => ( +
  • + <$ApplicationWithMessages + onClick={() => setMessageCenterActive(false)} + href={`/application?id=${String(application.id)}&openDrawer=1`} + > +
    + Hakemus {application.application_number} +
    +
    {application.company.name}
    +
    + {application.employee.first_name}{' '} + {application.employee.last_name} +
    +
    + +
    + +
  • + ))} +
+ + + ); +}; + +export default Header; diff --git a/frontend/benefit/handler/src/hooks/useApplicationsWithMessagesQuery.ts b/frontend/benefit/handler/src/hooks/useApplicationsWithMessagesQuery.ts new file mode 100644 index 0000000000..67f32b1eb7 --- /dev/null +++ b/frontend/benefit/handler/src/hooks/useApplicationsWithMessagesQuery.ts @@ -0,0 +1,42 @@ +import { BackendEndpoint } from 'benefit-shared/backend-api/backend-api'; +import { ApplicationData } from 'benefit-shared/types/application'; +import { useTranslation } from 'next-i18next'; +import { useQuery, UseQueryResult } from 'react-query'; +import showErrorToast from 'shared/components/toast/show-error-toast'; +import useBackendAPI from 'shared/hooks/useBackendAPI'; + +const useApplicationMessagesQuery = (): UseQueryResult< + ApplicationData[], + Error +> => { + const { axios, handleResponse } = useBackendAPI(); + const { t } = useTranslation(); + + const handleError = (): void => { + showErrorToast( + t('common:applications.list.errors.fetch.label'), + t('common:applications.list.errors.fetch.text', { status: 'error' }) + ); + }; + + const params = {}; + + return useQuery( + ['messageNotifications'], + async () => { + const res = axios.get( + `${BackendEndpoint.APPLICATIONS_WITH_UNREAD_MESSAGES}`, + { + params, + } + ); + return handleResponse(res); + }, + { + refetchInterval: 1225 * 1000, + onError: () => handleError(), + } + ); +}; + +export default useApplicationMessagesQuery; diff --git a/frontend/benefit/handler/src/hooks/useMarkLastMessageUnreadQuery.ts b/frontend/benefit/handler/src/hooks/useMarkLastMessageUnreadQuery.ts index 77cca34dc4..107e964865 100644 --- a/frontend/benefit/handler/src/hooks/useMarkLastMessageUnreadQuery.ts +++ b/frontend/benefit/handler/src/hooks/useMarkLastMessageUnreadQuery.ts @@ -2,7 +2,7 @@ import { BackendEndpoint } from 'benefit-shared/backend-api/backend-api'; import { MESSAGE_URLS } from 'benefit-shared/constants'; import { MessageData } from 'benefit-shared/types/application'; import { useTranslation } from 'next-i18next'; -import { useMutation, UseMutationResult } from 'react-query'; +import { useMutation, UseMutationResult, useQueryClient } from 'react-query'; import showErrorToast from 'shared/components/toast/show-error-toast'; import useBackendAPI from 'shared/hooks/useBackendAPI'; @@ -11,6 +11,7 @@ const useMarkLastMessageUnreadQuery = ( ): UseMutationResult => { const { axios, handleResponse } = useBackendAPI(); const { t } = useTranslation(); + const queryClient = useQueryClient(); const handleError = (): void => { showErrorToast( @@ -29,6 +30,12 @@ const useMarkLastMessageUnreadQuery = ( }, { onError: () => handleError(), + onSuccess: () => { + setTimeout( + () => void queryClient.invalidateQueries(['messageNotifications']), + 10 + ); + }, } ); }; diff --git a/frontend/benefit/handler/src/hooks/useMarkMessagesReadQuery.ts b/frontend/benefit/handler/src/hooks/useMarkMessagesReadQuery.ts index 6c4894c76a..56a352212a 100644 --- a/frontend/benefit/handler/src/hooks/useMarkMessagesReadQuery.ts +++ b/frontend/benefit/handler/src/hooks/useMarkMessagesReadQuery.ts @@ -2,7 +2,7 @@ import { BackendEndpoint } from 'benefit-shared/backend-api/backend-api'; import { MESSAGE_URLS } from 'benefit-shared/constants'; import { MessageData } from 'benefit-shared/types/application'; import { useTranslation } from 'next-i18next'; -import { useMutation, UseMutationResult } from 'react-query'; +import { useMutation, UseMutationResult, useQueryClient } from 'react-query'; import showErrorToast from 'shared/components/toast/show-error-toast'; import useBackendAPI from 'shared/hooks/useBackendAPI'; @@ -11,6 +11,7 @@ const useMarkMessagesReadQuery = ( ): UseMutationResult => { const { axios, handleResponse } = useBackendAPI(); const { t } = useTranslation(); + const queryClient = useQueryClient(); const handleError = (): void => { showErrorToast( @@ -25,10 +26,17 @@ const useMarkMessagesReadQuery = ( const res = axios.post( `${BackendEndpoint.HANDLER_APPLICATIONS}${applicationId}/${MESSAGE_URLS.MESSAGES}mark_read/` ); + return void handleResponse(res); }, { onError: () => handleError(), + onSuccess: () => { + setTimeout( + () => void queryClient.invalidateQueries(['messageNotifications']), + 10 + ); + }, } ); }; diff --git a/frontend/benefit/shared/src/backend-api/backend-api.ts b/frontend/benefit/shared/src/backend-api/backend-api.ts index aabb0cac57..acff90d3af 100644 --- a/frontend/benefit/shared/src/backend-api/backend-api.ts +++ b/frontend/benefit/shared/src/backend-api/backend-api.ts @@ -20,10 +20,13 @@ export const BackendEndpoint = { GET_ORGANISATION: '/v1/company/get/', APPLICATION_ALTERATION: '/v1/applicationalterations/', HANDLER_APPLICATION_ALTERATION: '/v1/handlerapplicationalterations/', - HANDLER_APPLICATION_ALTERATION_UPDATE_WITH_CSV: 'v1/handlerapplicationalterations/update_with_csv/', + HANDLER_APPLICATION_ALTERATION_UPDATE_WITH_CSV: + 'v1/handlerapplicationalterations/update_with_csv/', DECISION_PROPOSAL_TEMPLATE: 'v1/decision-proposal-sections/', DECISION_PROPOSAL_DRAFT: 'v1/decision-proposal-drafts/', SEARCH: 'v1/search/', + APPLICATIONS_WITH_UNREAD_MESSAGES: + 'v1/handlerapplications/with_unread_messages/', } as const; const singleBatchBase = (id: string): string => diff --git a/frontend/shared/src/components/header/Header.tsx b/frontend/shared/src/components/header/Header.tsx index d9214187b0..fd2be78f3c 100644 --- a/frontend/shared/src/components/header/Header.tsx +++ b/frontend/shared/src/components/header/Header.tsx @@ -38,6 +38,7 @@ export type HeaderProps = { theme?: ThemeOption; hideLogin?: boolean; onTitleClick?: () => void; + className?: string; }; const Header: React.FC = ({ @@ -55,6 +56,7 @@ const Header: React.FC = ({ hideLogin, theme, onTitleClick, + className, }) => { const { locale, @@ -80,7 +82,7 @@ const Header: React.FC = ({ ); return ( -
+
(style: TemplateStringsArray | string): string => - `@media screen and (min-width: ${breakpoints[key]}px) { ${style.toString()} }`; +export const respondAbove = + (key: keyof typeof breakpoints) => + (style: TemplateStringsArray | string): string => + `@media screen and (min-width: ${ + breakpoints[key] + }px) { ${style.toString()} }`; -export const respondBelow = (key: keyof typeof breakpoints) => (style: TemplateStringsArray | string): string => - `@media screen and (max-width: ${breakpoints[key] - 1}px) { ${style.toString()} }`; +export const respondAbovePx = + (px: string | number) => + (style: TemplateStringsArray | string): string => + `@media screen and (min-width: ${String(px).replace( + 'px', + '' + )}px) { ${style.toString()} }`; -export const respondBetween = (min: keyof typeof breakpoints, max: keyof typeof breakpoints) => (style: TemplateStringsArray | string): string => - `@media screen and (min-width: ${breakpoints[min]}px) and (max-width: ${breakpoints[max] - 1}px) { ${style.toString()} }`; +export const respondBelow = + (key: keyof typeof breakpoints) => + (style: TemplateStringsArray | string): string => + `@media screen and (max-width: ${ + breakpoints[key] - 1 + }px) { ${style.toString()} }`; + +export const respondBetween = + (min: keyof typeof breakpoints, max: keyof typeof breakpoints) => + (style: TemplateStringsArray | string): string => + `@media screen and (min-width: ${breakpoints[min]}px) and (max-width: ${ + breakpoints[max] - 1 + }px) { ${style.toString()} }`;