Skip to content

Commit

Permalink
Overnight reservations (#317)
Browse files Browse the repository at this point in the history
Most notable changes:
- added a new overnight calendar component
- overnight calendar is shown when resource is set to be in overnight mode (resource and reservation pages)
- all time fields support times and durations in overnight situations
  • Loading branch information
SanttuA authored Apr 24, 2024
1 parent 9a617b5 commit 3dc0ca0
Show file tree
Hide file tree
Showing 43 changed files with 3,245 additions and 114 deletions.
3 changes: 3 additions & 0 deletions app/actions/uiActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ const changeContrast = createAction(types.UI.CHANGE_CONTRAST);

const changeFontSize = createAction(types.UI.CHANGE_FONTSIZE, fontsize => fontsize);

const setSelectedDatetimes = createAction(types.UI.SET_SELECTED_DATETIMES);

export {
cancelReservationEdit,
cancelReservationEditInInfoModal,
Expand Down Expand Up @@ -163,4 +165,5 @@ export {
unselectAdminResourceType,
changeContrast,
changeFontSize,
setSelectedDatetimes,
};
1 change: 1 addition & 0 deletions app/constants/ActionTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,6 @@ export default {
TOGGLE_SEARCH_SHOW_MAP: 'TOGGLE_SEARCH_SHOW_MAP',
TOGGLE_TIME_SLOT: 'TOGGLE_TIME_SLOT',
UNSELECT_ADMIN_RESOURCE_TYPE: 'UNSELECT_ADMIN_RESOURCE_TYPE',
SET_SELECTED_DATETIMES: 'SET_SELECTED_DATETIMES',
}
};
15 changes: 15 additions & 0 deletions app/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@
"common.starFieldsAreRequired": "The fields marked with an asterisk (*) are mandatory.",
"common.taxesTotal": "Total taxes",
"common.taxlessTotal": "Tax-free total",
"common.time.begin": "Begins",
"common.time.duration": "Duration",
"common.time.end": "Ends",
"common.today": "Today",
"common.total": "total",
"common.unit.time.hour": "h",
"common.unit.time.day": "day",
"common.unit.time.day.short": "d",
"common.unit.time.week": "week",
"common.unitPieces": "{unitPieces, plural, one {pc} other {pcs}}",
"common.userNameLabel": "Account name",
Expand Down Expand Up @@ -245,11 +249,22 @@
"Notifications.loginToReserveStrongAuth": "Log in with Suomi.fi to reserve these premises.",
"Notifications.massCancelSuccessful": "Cancellations of the reservations were successful.",
"Notifications.noPermission": "You do not have permission to perform this action.",
"Notifications.overnight.notSelectable": "The specified day is not selectable.",
"Notifications.overnight.notSelectableStart": "The specified day cannot be chosen as the start date.",
"Notifications.noRightToReserve": "You do not have the right to make this reservation.",
"Notifications.reservationDeleteSuccessMessage": "Your reservation was successfully cancelled.",
"Notifications.reservationUpdateSuccessMessage": "Your reservation has been updated.",
"Notifications.selectTimeToReserve.warning": "The reservation overlaps with the existing reservation or is outside the opening hours.",
"Notifications.selectTimeToReserve": "Select the time for which you wish to make the reservation",
"Overnight.belowMinAlert": "The selected time is shorter than the required minimum duration",
"Overnight.calendar": "Calendar",
"Overnight.currentMonth": "Current month",
"Overnight.legend.available": "Available",
"Overnight.legend.notSelectable": "Not selectable",
"Overnight.legend.ownSelection": "Own selection",
"Overnight.legend.reserved": "Reserved",
"Overnight.nextMonth": "Next month",
"Overnight.prevMonth": "Previous month",
"Partners.kaupunginkirjatoImageAlt": "Helsinki City Library",
"Partners.nuorisoasiainkeskusImageAlt": "City of Helsinki – Youth Department",
"Partners.varhaiskasvatusvirastoImageAlt": "City of Helsinki – Department of Early Education and Care",
Expand Down
15 changes: 15 additions & 0 deletions app/i18n/messages/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@
"common.starFieldsAreRequired": "Tähdellä (*) merkityt tiedot ovat pakollisia.",
"common.taxesTotal": "Verot yhteensä",
"common.taxlessTotal": "Veroton yhteensä",
"common.time.begin": "Alkaa",
"common.time.duration": "Kesto",
"common.time.end": "Loppuu",
"common.today": "Tänään",
"common.total": "yhteensä",
"common.unit.time.hour": "h",
"common.unit.time.day": "päivä",
"common.unit.time.day.short": "pv",
"common.unit.time.week": "viikko",
"common.unitPieces": "{unitPieces, plural, one {kpl} other {kpl}}",
"common.userNameLabel": "Tilin nimi",
Expand Down Expand Up @@ -246,10 +250,21 @@
"Notifications.massCancelSuccessful": "Varausten peruminen onnistui.",
"Notifications.noPermission": "Sinulla ei ole oikeutta suorittaa tätä toimintoa.",
"Notifications.noRightToReserve": "Sinulla ei ole oikeutta tehdä tätä varausta.",
"Notifications.overnight.notSelectable": "Kyseinen päivä ei ole valittavissa",
"Notifications.overnight.notSelectableStart": "Kyseistä päivää ei voi valita aloituspäiväksi",
"Notifications.reservationDeleteSuccessMessage": "Varauksen peruminen onnistui.",
"Notifications.reservationUpdateSuccessMessage": "Varaus päivitetty.",
"Notifications.selectTimeToReserve.warning": "Varaus menee päällekkäin olemassa olevan varauksen kanssa tai se on aukioloaikojen ulkopuolella.",
"Notifications.selectTimeToReserve": "Valitse aika, jolle haluat tehdä varauksen.",
"Overnight.belowMinAlert": "Valittu aika on alle vaaditun vähimmäispituuden",
"Overnight.calendar": "Kalenteri",
"Overnight.currentMonth": "Nykyinen kuukausi",
"Overnight.legend.available": "Vapaa",
"Overnight.legend.notSelectable": "Ei valittavissa",
"Overnight.legend.ownSelection": "Oma valinta",
"Overnight.legend.reserved": "Varattu",
"Overnight.nextMonth": "Seuraava kuukausi",
"Overnight.prevMonth": "Edellinen kuukausi",
"Partners.kaupunginkirjatoImageAlt": "Helsingin kaupunginkirjasto",
"Partners.nuorisoasiainkeskusImageAlt": "Helsingin kaupunki - nuorisoasiainkeskus",
"Partners.varhaiskasvatusvirastoImageAlt": "Helsingin kaupunki - Varhaiskasvatusvirasto",
Expand Down
15 changes: 15 additions & 0 deletions app/i18n/messages/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@
"common.starFieldsAreRequired": "Uppgifterna markerade med en asterisk (*) är obligatoriska.",
"common.taxesTotal": "Totala skatter",
"common.taxlessTotal": "Skattefritt totalt",
"common.time.begin": "Börjar",
"common.time.duration": "Varaktighet",
"common.time.end": "Slutar",
"common.today": "Idag",
"common.total": "totalt",
"common.unit.time.hour": "h",
"common.unit.time.day": "dag",
"common.unit.time.day.short": "d",
"common.unit.time.week": "vecka",
"common.unitPieces": "{unitPieces, plural, one {st} other {st}}",
"common.userNameLabel": "Konto namn",
Expand Down Expand Up @@ -248,10 +252,21 @@
"Notifications.massCancelSuccessful": "Avbokningarna lyckades.",
"Notifications.noPermission": "Du har inte behörighet att utföra denna åtgärd.",
"Notifications.noRightToReserve": "Du har inte rätt att göra denna bokning.",
"Notifications.overnight.notSelectable": "Den angivna dagen går inte att välja.",
"Notifications.overnight.notSelectableStart": "Den angivna dagen kan inte väljas som startdatum.",
"Notifications.reservationDeleteSuccessMessage": "Bokningen avbokades.",
"Notifications.reservationUpdateSuccessMessage": "Bokningen uppdaterades.",
"Notifications.selectTimeToReserve.warning": "Bokningen överlappar med befintlig bokning eller ligger utanför öppettiderna.",
"Notifications.selectTimeToReserve": "Välj en tidpunkt som du vill boka.",
"Overnight.belowMinAlert": "Den valda tiden är kortare än det minsta krävda längd",
"Overnight.calendar": "Kalender",
"Overnight.currentMonth": "Nuvarande månad",
"Overnight.legend.available": "Tillgänglig",
"Overnight.legend.notSelectable": "Ej valbar",
"Overnight.legend.ownSelection": "Egen val",
"Overnight.legend.reserved": "Reserverad",
"Overnight.nextMonth": "Nästa månad",
"Overnight.prevMonth": "Föregående månad",
"Partners.kaupunginkirjatoImageAlt": "Helsingfors stadsbibliotek",
"Partners.nuorisoasiainkeskusImageAlt": "Helsingfors stad - ungdomscentralen",
"Partners.varhaiskasvatusvirastoImageAlt": "Helsingfors stad - Barnomsorgsverket",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ describe('manage-reservations/list/listUtils', () => {
const expectedResult = `${begin.format('ddd L HH:mm')} - ${end.format('HH:mm')}`;
expect(getDateAndTime(reservation)).toBe(expectedResult);
});
test('returns correct string with given multiday reservation', () => {
const begin = moment('2021-02-08 09:30');
const end = moment('2021-02-09 10:30');
const reservation = Reservation.build({ begin, end });
const expectedResult = `${begin.format('D.M.YYYY')} - ${end.format('D.M.YYYY')}`;
expect(getDateAndTime(reservation)).toBe(expectedResult);
});
});

describe('getNormalizedReservation', () => {
Expand Down
6 changes: 6 additions & 0 deletions app/pages/manage-reservations/list/listUtils.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import moment from 'moment';

import { isMultiday } from '../../../utils/timeUtils';

/**
* Returns formatted reservation begin datetime to end time string.
* @param {object} reservation
* @returns {string} formatted reservation time range "datetime - time"
*/
export function getDateAndTime(reservation) {
const isReservationMultiday = isMultiday(reservation.begin, reservation.end);
const begin = moment(reservation.begin);
const end = moment(reservation.end);

if (isReservationMultiday) {
return `${begin.format('D.M.YYYY')} - ${end.format('D.M.YYYY')}`;
}
return `${begin.format('ddd L HH:mm')} - ${end.format('HH:mm')}`;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { getReservationCustomerGroupName } from 'utils/reservationUtils';
import constants from '../../../constants/AppConstants';
import { checkQualityToolsLink } from '../../../shared/quality-tools-form/qualityToolsUtils';
import ThankYouAndFeedback from './ThankYouAndFeedback';
import { isMultiday } from '../../../utils/timeUtils';
import ReservationOvernightDate from '../../../shared/reservation-date/ReservationOvernightDate';

class ReservationConfirmation extends Component {
static propTypes = {
Expand Down Expand Up @@ -132,14 +134,19 @@ class ReservationConfirmation extends Component {
email = user.email;
}

const reservationIsMultiday = isMultiday(reservation.begin, reservation.end);

return (
<Row className="app-ReservationConfirmation">
<Col lg={6} md={12} xs={12}>
<Well>
<h2 className="app-ReservationPage__header">
{t(`ReservationConfirmation.reservation${isEdited ? 'Edited' : 'Created'}Title`)}
</h2>
<ReservationDate beginDate={reservation.begin} endDate={reservation.end} />
{reservationIsMultiday
? <ReservationOvernightDate beginDate={reservation.begin} endDate={reservation.end} />
: <ReservationDate beginDate={reservation.begin} endDate={reservation.end} />
}
<p className="app-ReservationConfirmation__resource-name">
<img
alt=""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { shallowWithIntl } from 'utils/testUtils';
import ReservationConfirmation from './ReservationConfirmation';
import { checkQualityToolsLink } from '../../../shared/quality-tools-form/qualityToolsUtils';
import ThankYouAndFeedback from './ThankYouAndFeedback';
import ReservationOvernightDate from '../../../shared/reservation-date/ReservationOvernightDate';

jest.mock('../../../shared/quality-tools-form/qualityToolsUtils', () => {
const originalModule = jest.requireActual('../../../shared/quality-tools-form/qualityToolsUtils');
Expand Down Expand Up @@ -99,11 +100,45 @@ describe('pages/reservation/reservation-confirmation/ReservationConfirmation', (
});
});

test('renders ReservationDate with correct props', () => {
const reservationDate = getWrapper().find(ReservationDate);
expect(reservationDate).toHaveLength(1);
expect(reservationDate.prop('beginDate')).toBe(defaultProps.reservation.begin);
expect(reservationDate.prop('endDate')).toBe(defaultProps.reservation.end);
describe('when reservation is single day', () => {
const reservation = Reservation.build({
begin: '2017-01-01',
end: '2017-01-01',
user: User.build(),
});

test('renders ReservationDate', () => {
const reservationDate = getWrapper({ reservation }).find(ReservationDate);
expect(reservationDate).toHaveLength(1);
expect(reservationDate.prop('beginDate')).toBe(reservation.begin);
expect(reservationDate.prop('endDate')).toBe(reservation.end);
});

test('doesnt render ReservationOvernightDate', () => {
const reservationOvernightDate = getWrapper({ reservation }).find(ReservationOvernightDate);
expect(reservationOvernightDate).toHaveLength(0);
});
});


describe('when reservation is multiday', () => {
const reservation = Reservation.build({
begin: '2017-01-01',
end: '2017-01-02',
user: User.build(),
});

test('doesnt render ReservationDate', () => {
const reservationDate = getWrapper({ reservation }).find(ReservationDate);
expect(reservationDate).toHaveLength(0);
});

test('renders ReservationOvernightDate', () => {
const reservationOvernightDate = getWrapper({ reservation }).find(ReservationOvernightDate);
expect(reservationOvernightDate).toHaveLength(1);
expect(reservationOvernightDate.prop('beginDate')).toBe(reservation.begin);
expect(reservationOvernightDate.prop('endDate')).toBe(reservation.end);
});
});

test('renders resource name', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import Well from 'react-bootstrap/lib/Well';
import moment from 'moment';

import injectT from '../../../i18n/injectT';
import { getPrettifiedDuration } from 'utils/timeUtils';
import { formatDetailsDatetimes } from 'utils/timeUtils';
import SingleReservationDetail from './SingleReservationDetail';

function ReservationDetails({
customerGroupName, orderPrice, paymentMethod, resourceName, selectedTime, unitName, t
}) {
let reservationTime = '';

if (selectedTime) {
const beginText = moment(selectedTime.begin).format('D.M.YYYY HH:mm');
const endText = moment(selectedTime.end).format('HH:mm');
const duration = getPrettifiedDuration(selectedTime.begin, selectedTime.end);
reservationTime = `${beginText}${endText} (${duration})`;
reservationTime = formatDetailsDatetimes(selectedTime.begin, selectedTime.end, t('common.unit.time.day.short'));
}

return (
Expand Down
61 changes: 38 additions & 23 deletions app/pages/reservation/reservation-time/ReservationTime.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { injectT } from 'i18n';
import ReservationCalendar from 'pages/resource/reservation-calendar';
import ResourceCalendar from 'shared/resource-calendar';
import ReservationDetails from '../reservation-details/ReservationDetails';
import OvernightCalendar from '../../../shared/overnight-calendar/OvernightCalendar';

class ReservationTime extends Component {
static propTypes = {
Expand Down Expand Up @@ -63,29 +64,43 @@ class ReservationTime extends Component {
<h2 className="visually-hidden reservationTime__Header">{t('ReservationPhase.timeTitle')}</h2>
<Row>
<Col lg={8} sm={12}>
<ResourceCalendar
onDateChange={this.handleDateChange}
resourceId={resource.id}
selectedDate={date}
/>
<ReservationCalendar
history={history}
location={location}
params={{ ...params, id: resource.id }}
/>
<div className="app-ReservationTime__controls">
<Button bsStyle="warning" className="cancel_Button" onClick={onCancel}>
{t('ReservationInformationForm.cancelEdit')}
</Button>
<Button
bsStyle="primary"
className="next_Button"
disabled={isEmpty(selectedReservation) || isEmpty(selectedTime)}
onClick={onConfirm}
>
{t('common.continue')}
</Button>
</div>
{resource.overnightReservations ? (
<OvernightCalendar
handleDateChange={this.handleDateChange}
history={history}
onEditCancel={onCancel}
onEditConfirm={onConfirm}
params={{ ...params, id: resource.id }}
reservationId={selectedReservation.id}
resource={resource}
/>
) : (
<React.Fragment>
<ResourceCalendar
onDateChange={this.handleDateChange}
resourceId={resource.id}
selectedDate={date}
/>
<ReservationCalendar
history={history}
location={location}
params={{ ...params, id: resource.id }}
/>
<div className="app-ReservationTime__controls">
<Button bsStyle="warning" className="cancel_Button" onClick={onCancel}>
{t('ReservationInformationForm.cancelEdit')}
</Button>
<Button
bsStyle="primary"
className="next_Button"
disabled={isEmpty(selectedReservation) || isEmpty(selectedTime)}
onClick={onConfirm}
>
{t('common.continue')}
</Button>
</div>
</React.Fragment>
)}
</Col>
<Col lg={4} sm={12}>
<ReservationDetails
Expand Down
Loading

0 comments on commit 3dc0ca0

Please sign in to comment.