Skip to content

Commit d1d89cc

Browse files
committed
feat: unenrolment should be available only over 48h before occurrence
KK-1436. Add `AppConfig.enrolmentCancellationTimeLimitHours` that can be used to configure the time (in hours) that limits the cancellation time. Add description texts related to enrolment cancellation.
1 parent dcd6dcc commit d1d89cc

9 files changed

Lines changed: 166 additions & 7 deletions

File tree

src/common/translation/i18n/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@
324324
"successToast": {
325325
"heading": "Registration for event successful",
326326
"paragraph": "We sent you a confirmation message by email where you can find more information about the event"
327+
},
328+
"cancellation": {
329+
"enabledDescription": "If you cannot attend the event, please cancel your registration at least {{unerolHoursBeforeOccurrence}} hours before the event.",
330+
"disabledDescription": "The event starts in less than {{unerolHoursBeforeOccurrence}} hours, unfortunately you can no longer cancel your registration."
327331
}
328332
},
329333
"enrollPage": {

src/common/translation/i18n/fi.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@
324324
"successToast": {
325325
"heading": "Tapahtumaan ilmoittautuminen onnistui!",
326326
"paragraph": "Lähetimme varmistusviestin sähköpostiisi, löydät sieltä tapahtuman lisätiedot"
327+
},
328+
"cancellation": {
329+
"enabledDescription": "Jos ette pääse osallistumaan tapahtumaan, peru ilmoittautumisenne viimeistään {{unerolHoursBeforeOccurrence}} tuntia ennen tapahtumaa.",
330+
"disabledDescription": "Tapahtuma alkaa alle {{unerolHoursBeforeOccurrence}} tunnin päästä, joten et valitettavasti voi enää perua ilmoittautumista."
327331
}
328332
},
329333
"enrollPage": {

src/common/translation/i18n/sv.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@
324324
"successToast": {
325325
"heading": "Din anmälan till evenemanget lyckades!",
326326
"paragraph": "Vi skickade en bekräftelse till din epost, den innehåller mer information om evenemanget"
327+
},
328+
"cancellation": {
329+
"enabledDescription": "Om du inte kan delta i evenemanget, vänligen avboka din anmälan senast {{unerolHoursBeforeOccurrence}} timmar före evenemanget.",
330+
"disabledDescription": "Evenemanget börjar om mindre än {{unerolHoursBeforeOccurrence}} timmar, så tyvärr kan du inte längre avboka din anmälan."
327331
}
328332
},
329333
"enrollPage": {

src/domain/app/AppConfig.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,17 @@ class AppConfig {
304304
static get datetimeFormat() {
305305
return 'dd.MM.yyyy HH:mm:ss';
306306
}
307+
308+
/**
309+
* Time limit in hours for cancelling an enrolment.
310+
* This is the time before the event occurrence when a user can no longer cancel their enrolment.
311+
* Read env variable `VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS`. Defaults to 48 hours if not set.
312+
*/
313+
static get enrolmentCancellationTimeLimitHours() {
314+
return (
315+
Number(import.meta.env.VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS) || 48
316+
); // Default to 48 hours if not set
317+
}
307318
}
308319

309320
// Accept both variable and name so that variable can be correctly replaced

src/domain/event/EventIsEnrolled.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import EventPage from './EventPage';
1616
import ErrorMessage from '../../common/components/error/Error';
1717
import Button from '../../common/components/button/Button';
1818
import useChildRouteGoBackTo from '../profile/children/child/useChildRouteGoBackTo';
19+
import { shouldAllowUnenrolment } from './EventUtils';
20+
import AppConfig from '../app/AppConfig';
1921

2022
const EventIsEnrolled = () => {
2123
const { t } = useTranslation();
@@ -39,6 +41,10 @@ const EventIsEnrolled = () => {
3941

4042
if (!data?.occurrence) return errorMessage;
4143

44+
const disableUnenrolling = !shouldAllowUnenrolment(data.occurrence.time);
45+
const unerolHoursBeforeOccurrence =
46+
AppConfig.enrolmentCancellationTimeLimitHours;
47+
4248
return (
4349
<EventPage event={data.occurrence.event} backTo={goBackTo}>
4450
<OccurrenceInfo
@@ -54,13 +60,28 @@ const EventIsEnrolled = () => {
5460
)}
5561
</div>
5662
<h2>{t('event.cancellation.heading')}</h2>
63+
<p id="eventCancellationDescription">
64+
{disableUnenrolling
65+
? t('enrollment.cancellation.disabledDescription', {
66+
unerolHoursBeforeOccurrence,
67+
})
68+
: t('enrollment.cancellation.enabledDescription', {
69+
unerolHoursBeforeOccurrence,
70+
})}
71+
</p>
5772
<div className={styles.cancelButtonWrapper}>
58-
<Button variant="secondary" onClick={() => setIsOpen(true)}>
73+
<Button
74+
id="unenrolButton"
75+
variant="secondary"
76+
aria-describedby="eventCancellationDescription"
77+
onClick={() => setIsOpen(true)}
78+
disabled={disableUnenrolling}
79+
>
5980
{t('event.cancellation.buttonText')}
6081
</Button>
6182
</div>
6283
<VenueFeatures venue={data.occurrence.venue} />
63-
{isOpen && (
84+
{isOpen && !disableUnenrolling && (
6485
<UnenrolModal
6586
isOpen={isOpen}
6687
setIsOpen={setIsOpen}

src/domain/event/EventUtils.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { IconGroup } from 'hds-react';
22
import { addMinutes } from 'date-fns/addMinutes';
33
import uniqBy from 'lodash/uniqBy';
4+
import { differenceInHours } from 'date-fns/differenceInHours';
45

56
import { formatTime, newDate } from '../../common/time/utils';
67
import {
@@ -11,6 +12,7 @@ import {
1112
import { EventParticipantsPerInvite as EventParticipantsPerInviteEnum } from '../api/generatedTypes/graphql';
1213
import RelayList from '../api/relayList';
1314
import { OccurrenceNode, Occurrences } from './types/EventQueryTypes';
15+
import AppConfig from '../app/AppConfig';
1416

1517
const OccurrenceList = RelayList<OccurrenceNode>();
1618

@@ -66,3 +68,20 @@ export function getParticipantsIcon(iconType: EventParticipantsPerInviteEnum) {
6668
return <IconGroup />;
6769
}
6870
}
71+
72+
/**
73+
* Should the user be allowed to unenrol from an event occurrence?
74+
* This function checks if the current time is at least
75+
* `AppConfig.enrolmentCancellationTimeLimitHours` (e.g. 48) hours
76+
* before the occurrence time. If it is, the user can unenrol;
77+
* otherwise, they cannot.
78+
*/
79+
export const shouldAllowUnenrolment = (
80+
occurrenceTime: Date,
81+
enrolmentCancellationTimeLimitHours = AppConfig.enrolmentCancellationTimeLimitHours
82+
) => {
83+
const hoursDiff = differenceInHours(occurrenceTime, new Date(), {
84+
roundingMethod: 'ceil',
85+
});
86+
return Boolean(hoursDiff >= enrolmentCancellationTimeLimitHours);
87+
};

src/domain/event/__tests__/EventIsEnrolled.test.tsx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { MockedResponse } from '@apollo/client/testing';
2+
import { screen } from '@testing-library/react';
3+
import range from 'lodash/range';
24

35
import { customRender as render } from '../../../common/test/customRender';
46
import EventIsEnrolled from '../EventIsEnrolled';
57
import occurrenceQuery from '../queries/occurrenceQuery';
8+
import {
9+
EventParticipantsPerInvite,
10+
OccurrenceQuery,
11+
} from '../../api/generatedTypes/graphql';
612

713
const emptyOccurrenceMock: MockedResponse = {
814
request: {
@@ -16,9 +22,81 @@ const emptyOccurrenceMock: MockedResponse = {
1622
},
1723
};
1824

19-
const mocks: MockedResponse[] = [emptyOccurrenceMock];
25+
const occurrenceMock: OccurrenceQuery = {
26+
occurrence: {
27+
__typename: 'OccurrenceNode',
28+
id: 'occurrence-id',
29+
time: new Date(Date.now() + 48 * 60 * 60 * 1000),
30+
event: {
31+
__typename: 'EventNode',
32+
id: 'event-id',
33+
description: 'event description',
34+
image: '',
35+
imageAltText: null,
36+
shortDescription: null,
37+
name: null,
38+
duration: null,
39+
participantsPerInvite: EventParticipantsPerInvite.ChildAndGuardian,
40+
eventGroup: null,
41+
},
42+
remainingCapacity: null,
43+
childHasFreeSpotNotificationSubscription: null,
44+
venue: {
45+
__typename: 'VenueNode',
46+
id: '',
47+
name: null,
48+
address: null,
49+
accessibilityInfo: null,
50+
arrivalInstructions: null,
51+
additionalInfo: null,
52+
wwwUrl: null,
53+
wcAndFacilities: null,
54+
},
55+
},
56+
};
2057

2158
it('renders snapshot correctly', () => {
22-
const { container } = render(<EventIsEnrolled />, mocks);
59+
const { container } = render(<EventIsEnrolled />, [emptyOccurrenceMock]);
2360
expect(container).toMatchSnapshot();
2461
});
62+
63+
describe('unenrolment is possible only 48 before the occurrence', async () => {
64+
it.each(
65+
range(46, 50).map((hoursBeforeOccurrence) => ({
66+
hoursBeforeOccurrence,
67+
isCancellationAvailable: hoursBeforeOccurrence >= 48,
68+
}))
69+
)(
70+
// eslint-disable-next-line max-len
71+
'shows unenrolment button enabled when occurrence is in $hoursBeforeOccurrence hours ($isCancellationAvailable)',
72+
async ({ hoursBeforeOccurrence, isCancellationAvailable }) => {
73+
const occurrenceQueryMock: MockedResponse = {
74+
request: {
75+
query: occurrenceQuery,
76+
variables: { id: undefined, childId: undefined },
77+
},
78+
result: {
79+
data: {
80+
...occurrenceMock,
81+
occurrence: {
82+
...occurrenceMock.occurrence,
83+
time: new Date(
84+
Date.now() + hoursBeforeOccurrence * 60 * 60 * 1000
85+
),
86+
},
87+
},
88+
},
89+
};
90+
render(<EventIsEnrolled />, [occurrenceQueryMock]);
91+
await screen.findByText('event description');
92+
const unenrolButton = await screen.findByRole('button', {
93+
name: 'Peru ilmoittautuminen',
94+
});
95+
if (isCancellationAvailable) {
96+
expect(unenrolButton).toBeEnabled();
97+
} else {
98+
expect(unenrolButton).toBeDisabled();
99+
}
100+
}
101+
);
102+
});

src/domain/event/enrol/Enrol.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import OccurrenceInfo from '../partial/OccurrenceInfo';
66
import Button from '../../../common/components/button/Button';
77
import EventOccurrenceNotificationControlButton from '../EventOccurrenceNotificationControlButton';
88
import { OccurrenceFields } from '../types/OccurrenceQueryTypes';
9+
import AppConfig from '../../app/AppConfig';
10+
import Paragraph from '../../../common/components/paragraph/Paragraph';
911

1012
type Props = {
1113
childId: string;
@@ -46,8 +48,16 @@ const Enrol = ({
4648
</h1>
4749
</div>
4850
<div className={styles.text}>
49-
{!isFull && t('enrollment.confirmationPage.description.text')}
50-
{isFull && t('enrollment.confirmationPage.description.full')}
51+
<Paragraph
52+
text={
53+
!isFull
54+
? t('enrollment.confirmationPage.description.text', {
55+
unerolHoursBeforeOccurrence:
56+
AppConfig.enrolmentCancellationTimeLimitHours,
57+
})
58+
: t('enrollment.confirmationPage.description.full')
59+
}
60+
/>
5161
</div>
5262
<OccurrenceInfo
5363
occurrence={occurrence}

src/domain/event/modal/UnenrolModal.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import ConfirmModal from '../../../common/components/confirm/ConfirmModal';
1616
import { saveChildEvents } from '../state/EventActions';
1717
import getEventOrEventGroupOccurrenceRefetchQueries from '../getEventOrEventGroupOccurrenceRefetchQueries';
18+
import AppConfig from '../../app/AppConfig';
1819

1920
interface UnenrolModalProps {
2021
isOpen: boolean;
@@ -88,7 +89,14 @@ const UnenrolModal = ({
8889
cancel={t('event.cancellation.confirmationModal.cancel.buttonText')}
8990
ok={t('event.cancellation.confirmationModal.confirm.buttonText')}
9091
answer={confirmUnenrol}
91-
/>
92+
>
93+
<p>
94+
{t('enrollment.cancellation.enabledDescription', {
95+
unerolHoursBeforeOccurrence:
96+
AppConfig.enrolmentCancellationTimeLimitHours,
97+
})}
98+
</p>
99+
</ConfirmModal>
92100
);
93101
};
94102

0 commit comments

Comments
 (0)