Skip to content

Commit

Permalink
feat: add unread messages notifier into header (hl-1410) (#3214)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
sirtawast authored Aug 26, 2024
1 parent d16171c commit 56a47c3
Show file tree
Hide file tree
Showing 11 changed files with 384 additions and 15 deletions.
13 changes: 13 additions & 0 deletions backend/benefit/applications/api/v1/application_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from applications.api.v1.serializers.application import (
ApplicantApplicationSerializer,
HandlerApplicationListSerializer,
HandlerApplicationSerializer,
)
from applications.api.v1.serializers.application_alteration import (
Expand Down Expand Up @@ -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()
Expand Down
27 changes: 27 additions & 0 deletions backend/benefit/applications/tests/test_applications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
181 changes: 181 additions & 0 deletions frontend/benefit/handler/src/components/header/Header.sc.ts
Original file line number Diff line number Diff line change
@@ -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;
`;
9 changes: 6 additions & 3 deletions frontend/benefit/handler/src/components/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -33,20 +35,21 @@ const Header: React.FC = () => {
);

return (
<BaseHeader
<$BaseHeader
title={t('common:appName')}
customItems={[
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== 'production' ? (
<TemporaryAhjoModeSwitch />
) : null,
<HeaderNotifier />,
]}
titleUrl={ROUTES.HOME}
skipToContentLabel={t('common:header.linkSkipToContent')}
menuToggleAriaLabel={t('common:header.menuToggleAriaLabel')}
isNavigationVisible={isNavigationVisible}
navigationItems={navigationItems}
onLanguageChange={handleLanguageChange}
theme="dark"
theme={'dark' as unknown as DefaultTheme}
login={{
isAuthenticated: !isLoginPage && isSuccess,
loginLabel: t('common:header.loginLabel'),
Expand Down
63 changes: 63 additions & 0 deletions frontend/benefit/handler/src/components/header/HeaderNotifier.tsx
Original file line number Diff line number Diff line change
@@ -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)}>
<IconBell />
<span>{applicationsWithMessages?.length}</span>
</$ToggleButton>
)}
<$Box $open={messageCenterActive} aria-hidden={!messageCenterActive}>
<h2>{t('common:header.messages')}</h2>
<ul>
{applicationsWithMessages.map((application) => (
<li>
<$ApplicationWithMessages
onClick={() => setMessageCenterActive(false)}
href={`/application?id=${String(application.id)}&openDrawer=1`}
>
<div>
<strong>Hakemus {application.application_number}</strong>
</div>
<div>{application.company.name}</div>
<div>
{application.employee.first_name}{' '}
{application.employee.last_name}
</div>
<div>
<IconAngleRight />
</div>
</$ApplicationWithMessages>
</li>
))}
</ul>
</$Box>
</$HeaderNotifier>
);
};

export default Header;
Original file line number Diff line number Diff line change
@@ -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<ApplicationData[], Error>(
['messageNotifications'],
async () => {
const res = axios.get<ApplicationData[]>(
`${BackendEndpoint.APPLICATIONS_WITH_UNREAD_MESSAGES}`,
{
params,
}
);
return handleResponse(res);
},
{
refetchInterval: 1225 * 1000,
onError: () => handleError(),
}
);
};

export default useApplicationMessagesQuery;
Loading

0 comments on commit 56a47c3

Please sign in to comment.