Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/common/translation/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@
},
"description": {
"full": "Unfortunately this event is full. Subscribe to receive a notification of available places.",
"text": "Check that the information below is correct for the event you wish to attend."
"text": "Check that the information below is correct for the event you wish to attend.\n If you cannot attend the event, please cancel your registration at least {{unerolHoursBeforeOccurrence}} hours before the event."
},
"heading": {
"full": "Registration for event {{eventName}} failed",
Expand Down Expand Up @@ -324,6 +324,10 @@
"successToast": {
"heading": "Registration for event successful",
"paragraph": "We sent you a confirmation message by email where you can find more information about the event"
},
"cancellation": {
"enabledDescription": "If you cannot attend the event, please cancel your registration at least {{unerolHoursBeforeOccurrence}} hours before the event.",
"disabledDescription": "The event starts in less than {{unerolHoursBeforeOccurrence}} hours, unfortunately you can no longer cancel your registration."
}
},
"enrollPage": {
Expand Down
6 changes: 5 additions & 1 deletion src/common/translation/i18n/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@
},
"description": {
"full": "Valitettavasti tämä tapahtuma on täynnä. Tilaa ilmoitus vapautuvista paikoista.",
"text": "Tarkista, että alla olevat tiedot vastaavat tapahtumaa, johon haluat osallistua."
"text": "Tarkista, että alla olevat tiedot vastaavat tapahtumaa, johon haluat osallistua.\n Jos ette pääse osallistumaan tapahtumaan, peru ilmoittautumisenne viimeistään {{unerolHoursBeforeOccurrence}} tuntia ennen tapahtumaa."
},
"heading": {
"full": "Ilmottautumien tapahtumaan {{eventName}} epäonnistui",
Expand Down Expand Up @@ -324,6 +324,10 @@
"successToast": {
"heading": "Tapahtumaan ilmoittautuminen onnistui!",
"paragraph": "Lähetimme varmistusviestin sähköpostiisi, löydät sieltä tapahtuman lisätiedot"
},
"cancellation": {
"enabledDescription": "Jos ette pääse osallistumaan tapahtumaan, peru ilmoittautumisenne viimeistään {{unerolHoursBeforeOccurrence}} tuntia ennen tapahtumaa.",
"disabledDescription": "Tapahtuma alkaa alle {{unerolHoursBeforeOccurrence}} tunnin päästä, joten et valitettavasti voi enää perua ilmoittautumista."
}
},
"enrollPage": {
Expand Down
6 changes: 5 additions & 1 deletion src/common/translation/i18n/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@
},
"description": {
"full": "Tyvärr är det här evenemanget fullt. Prenumerera för att få ett meddelande om tillgängliga platser.",
"text": "Kontrollera att informationen nedan motsvarar de evenemang du vill delta i."
"text": "Kontrollera att informationen nedan motsvarar de evenemang du vill delta i.\n Om du inte kan delta i evenemanget, vänligen avboka din anmälan senast {{unerolHoursBeforeOccurrence}} timmar före evenemanget."
},
"heading": {
"full": "Registreringen för händelsen {{eventName}} misslyckades",
Expand Down Expand Up @@ -324,6 +324,10 @@
"successToast": {
"heading": "Din anmälan till evenemanget lyckades!",
"paragraph": "Vi skickade en bekräftelse till din epost, den innehåller mer information om evenemanget"
},
"cancellation": {
"enabledDescription": "Om du inte kan delta i evenemanget, vänligen avboka din anmälan senast {{unerolHoursBeforeOccurrence}} timmar före evenemanget.",
"disabledDescription": "Evenemanget börjar om mindre än {{unerolHoursBeforeOccurrence}} timmar, så tyvärr kan du inte längre avboka din anmälan."
}
},
"enrollPage": {
Expand Down
11 changes: 11 additions & 0 deletions src/domain/app/AppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ class AppConfig {
static get datetimeFormat() {
return 'dd.MM.yyyy HH:mm:ss';
}

/**
* Time limit in hours for cancelling an enrolment.
* This is the time before the event occurrence when a user can no longer cancel their enrolment.
* Read env variable `VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS`. Defaults to 48 hours if not set.
*/
static get enrolmentCancellationTimeLimitHours() {
return (
Number(import.meta.env.VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS) || 48
); // Default to 48 hours if not set
}
}

// Accept both variable and name so that variable can be correctly replaced
Expand Down
25 changes: 23 additions & 2 deletions src/domain/event/EventIsEnrolled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import EventPage from './EventPage';
import ErrorMessage from '../../common/components/error/Error';
import Button from '../../common/components/button/Button';
import useChildRouteGoBackTo from '../profile/children/child/useChildRouteGoBackTo';
import { shouldAllowUnenrolment } from './EventUtils';
import AppConfig from '../app/AppConfig';

const EventIsEnrolled = () => {
const { t } = useTranslation();
Expand All @@ -39,6 +41,10 @@ const EventIsEnrolled = () => {

if (!data?.occurrence) return errorMessage;

const disableUnenrolling = !shouldAllowUnenrolment(data.occurrence.time);
const unerolHoursBeforeOccurrence =
AppConfig.enrolmentCancellationTimeLimitHours;

return (
<EventPage event={data.occurrence.event} backTo={goBackTo}>
<OccurrenceInfo
Expand All @@ -54,13 +60,28 @@ const EventIsEnrolled = () => {
)}
</div>
<h2>{t('event.cancellation.heading')}</h2>
<p id="eventCancellationDescription">
{disableUnenrolling
? t('enrollment.cancellation.disabledDescription', {
unerolHoursBeforeOccurrence,
})
: t('enrollment.cancellation.enabledDescription', {
unerolHoursBeforeOccurrence,
})}
</p>
<div className={styles.cancelButtonWrapper}>
<Button variant="secondary" onClick={() => setIsOpen(true)}>
<Button
id="unenrolButton"
variant="secondary"
aria-describedby="eventCancellationDescription"
onClick={() => setIsOpen(true)}
disabled={disableUnenrolling}
>
{t('event.cancellation.buttonText')}
</Button>
</div>
<VenueFeatures venue={data.occurrence.venue} />
{isOpen && (
{isOpen && !disableUnenrolling && (
<UnenrolModal
isOpen={isOpen}
setIsOpen={setIsOpen}
Expand Down
17 changes: 17 additions & 0 deletions src/domain/event/EventUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IconGroup } from 'hds-react';
import { addMinutes } from 'date-fns/addMinutes';
import uniqBy from 'lodash/uniqBy';
import { differenceInHours } from 'date-fns/differenceInHours';

import { formatTime, newDate } from '../../common/time/utils';
import {
Expand All @@ -11,6 +12,7 @@ import {
import { EventParticipantsPerInvite as EventParticipantsPerInviteEnum } from '../api/generatedTypes/graphql';
import RelayList from '../api/relayList';
import { OccurrenceNode, Occurrences } from './types/EventQueryTypes';
import AppConfig from '../app/AppConfig';

const OccurrenceList = RelayList<OccurrenceNode>();

Expand Down Expand Up @@ -66,3 +68,18 @@ export function getParticipantsIcon(iconType: EventParticipantsPerInviteEnum) {
return <IconGroup />;
}
}

/**
* Should the user be allowed to unenrol from an event occurrence?
* This function checks if the current time is at least
* `AppConfig.enrolmentCancellationTimeLimitHours` (e.g. 48) hours
* before the occurrence time. If it is, the user can unenrol;
* otherwise, they cannot.
*/
export const shouldAllowUnenrolment = (
occurrenceTime: Date,
enrolmentCancellationTimeLimitHours = AppConfig.enrolmentCancellationTimeLimitHours
) => {
const hoursDiff = differenceInHours(occurrenceTime, new Date());
return Boolean(hoursDiff >= enrolmentCancellationTimeLimitHours);
};
86 changes: 83 additions & 3 deletions src/domain/event/__tests__/EventIsEnrolled.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { MockedResponse } from '@apollo/client/testing';
import { screen } from '@testing-library/react';
import range from 'lodash/range';

import { customRender as render } from '../../../common/test/customRender';
import EventIsEnrolled from '../EventIsEnrolled';
import occurrenceQuery from '../queries/occurrenceQuery';
import {
EventParticipantsPerInvite,
OccurrenceQuery,
} from '../../api/generatedTypes/graphql';
import AppConfig from '../../app/AppConfig';

const emptyOccurrenceMock: MockedResponse = {
request: {
Expand All @@ -15,10 +22,83 @@ const emptyOccurrenceMock: MockedResponse = {
},
},
};

const mocks: MockedResponse[] = [emptyOccurrenceMock];
const now = Date.now();
const occurrenceMock: OccurrenceQuery = {
occurrence: {
__typename: 'OccurrenceNode',
id: 'occurrence-id',
time: new Date(now + 48 * 60 * 60 * 1000),
event: {
__typename: 'EventNode',
id: 'event-id',
description: 'event description',
image: '',
imageAltText: null,
shortDescription: null,
name: null,
duration: null,
participantsPerInvite: EventParticipantsPerInvite.ChildAndGuardian,
eventGroup: null,
},
remainingCapacity: null,
childHasFreeSpotNotificationSubscription: null,
venue: {
__typename: 'VenueNode',
id: '',
name: null,
address: null,
accessibilityInfo: null,
arrivalInstructions: null,
additionalInfo: null,
wwwUrl: null,
wcAndFacilities: null,
},
},
};

it('renders snapshot correctly', () => {
const { container } = render(<EventIsEnrolled />, mocks);
const { container } = render(<EventIsEnrolled />, [emptyOccurrenceMock]);
expect(container).toMatchSnapshot();
});

describe('unenrolment is possible only 48 before the occurrence', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work because of the ceiling rounding method used in differenceInHours in shouldAllowUnenrolment? I mean that the current time is not frozen here, and as the tests should take less than an hour the difference should evaluate to the same hour value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now there is 10s cap that should ensure that (and no ceiling rounding).

it.each(
range(46, 51).map((hoursBeforeOccurrence) => ({
hoursBeforeOccurrence,
isCancellationAvailable:
hoursBeforeOccurrence >= AppConfig.enrolmentCancellationTimeLimitHours, // 48h
}))
)(
// eslint-disable-next-line max-len
'shows unenrolment button enabled when occurrence is in $hoursBeforeOccurrence hours ($isCancellationAvailable)',
async ({ hoursBeforeOccurrence, isCancellationAvailable }) => {
const occurrenceQueryMock: MockedResponse = {
request: {
query: occurrenceQuery,
variables: { id: undefined, childId: undefined },
},
result: {
data: {
...occurrenceMock,
occurrence: {
...occurrenceMock.occurrence,
time: new Date(
now + hoursBeforeOccurrence * 60 * 60 * 1000 + 10_000
), // Add 10 seconds (for rounding) to ensure the time is in the future
},
},
},
};
render(<EventIsEnrolled />, [occurrenceQueryMock]);
await screen.findByText('event description');
const unenrolButton = await screen.findByRole('button', {
name: 'Peru ilmoittautuminen',
});
if (isCancellationAvailable) {
expect(unenrolButton).toBeEnabled();
} else {
expect(unenrolButton).toBeDisabled();
}
}
);
});
14 changes: 12 additions & 2 deletions src/domain/event/enrol/Enrol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import OccurrenceInfo from '../partial/OccurrenceInfo';
import Button from '../../../common/components/button/Button';
import EventOccurrenceNotificationControlButton from '../EventOccurrenceNotificationControlButton';
import { OccurrenceFields } from '../types/OccurrenceQueryTypes';
import AppConfig from '../../app/AppConfig';
import Paragraph from '../../../common/components/paragraph/Paragraph';

type Props = {
childId: string;
Expand Down Expand Up @@ -46,8 +48,16 @@ const Enrol = ({
</h1>
</div>
<div className={styles.text}>
{!isFull && t('enrollment.confirmationPage.description.text')}
{isFull && t('enrollment.confirmationPage.description.full')}
<Paragraph
text={
!isFull
? t('enrollment.confirmationPage.description.text', {
unerolHoursBeforeOccurrence:
AppConfig.enrolmentCancellationTimeLimitHours,
})
: t('enrollment.confirmationPage.description.full')
}
/>
</div>
<OccurrenceInfo
occurrence={occurrence}
Expand Down
10 changes: 9 additions & 1 deletion src/domain/event/modal/UnenrolModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import ConfirmModal from '../../../common/components/confirm/ConfirmModal';
import { saveChildEvents } from '../state/EventActions';
import getEventOrEventGroupOccurrenceRefetchQueries from '../getEventOrEventGroupOccurrenceRefetchQueries';
import AppConfig from '../../app/AppConfig';

interface UnenrolModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -88,7 +89,14 @@ const UnenrolModal = ({
cancel={t('event.cancellation.confirmationModal.cancel.buttonText')}
ok={t('event.cancellation.confirmationModal.confirm.buttonText')}
answer={confirmUnenrol}
/>
>
<p>
{t('enrollment.cancellation.enabledDescription', {
unerolHoursBeforeOccurrence:
AppConfig.enrolmentCancellationTimeLimitHours,
})}
</p>
</ConfirmModal>
);
};

Expand Down