From 3dc0ca063207a41d7f165e183e181ce9367de02b Mon Sep 17 00:00:00 2001 From: SanttuA Date: Wed, 24 Apr 2024 14:14:51 +0300 Subject: [PATCH] Overnight reservations (#317) 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 --- app/actions/uiActions.js | 3 + app/constants/ActionTypes.js | 1 + app/i18n/messages/en.json | 15 + app/i18n/messages/fi.json | 15 + app/i18n/messages/sv.json | 15 + .../list/__tests__/listUtils.spec.js | 7 + .../manage-reservations/list/listUtils.js | 6 + .../ReservationConfirmation.js | 9 +- .../ReservationConfirmation.spec.js | 45 +- .../reservation-details/ReservationDetails.js | 9 +- .../reservation-time/ReservationTime.js | 61 +- .../reservation-time/ReservationTime.spec.js | 103 ++- app/pages/resource/ResourcePage.js | 50 +- app/pages/resource/ResourcePage.spec.js | 79 +- .../reservation-list/ReservationListItem.js | 11 +- .../ReservationListItem.spec.js | 28 +- app/shared/_shared.scss | 1 + .../CompactReservationList.js | 13 +- .../reservation-info/ReservationEditForm.js | 18 +- .../ReservationEditForm.spec.js | 8 + .../overnight-calendar/OvernightCalendar.js | 304 +++++++ .../OvernightCalendarSelector.js | 24 + .../OvernightEditSummary.js | 66 ++ .../OvernightHiddenHeading.js | 25 + .../overnight-calendar/OvernightLegends.js | 38 + .../overnight-calendar/OvernightSummary.js | 60 ++ .../_overnight-calendar.scss | 367 +++++++++ .../overnight-calendar/overnightUtils.js | 522 ++++++++++++ .../tests/OvernightCalendar.spec.js | 143 ++++ .../tests/OvernightCalendarSelector.spec.js | 45 ++ .../tests/OvernightEditSummary.spec.js | 109 +++ .../tests/OvernightHiddenHeading.spec.js | 42 + .../tests/OvernightLegends.spec.js | 60 ++ .../tests/OvernightSummary.spec.js | 95 +++ .../tests/overnightUtils.spec.js | 749 ++++++++++++++++++ .../reservation-date/ReservationDate.js | 8 +- .../ReservationOvernightDate.js | 48 ++ .../ReservationOvernightDate.spec.js | 51 ++ .../reservation-date/_reservation-date.scss | 15 +- app/state/reducers/ui/reservationsReducer.js | 4 + .../reducers/ui/reservationsReducer.spec.js | 12 + app/utils/__tests__/timeUtils.spec.js | 27 + app/utils/timeUtils.js | 48 +- 43 files changed, 3245 insertions(+), 114 deletions(-) create mode 100644 app/shared/overnight-calendar/OvernightCalendar.js create mode 100644 app/shared/overnight-calendar/OvernightCalendarSelector.js create mode 100644 app/shared/overnight-calendar/OvernightEditSummary.js create mode 100644 app/shared/overnight-calendar/OvernightHiddenHeading.js create mode 100644 app/shared/overnight-calendar/OvernightLegends.js create mode 100644 app/shared/overnight-calendar/OvernightSummary.js create mode 100644 app/shared/overnight-calendar/_overnight-calendar.scss create mode 100644 app/shared/overnight-calendar/overnightUtils.js create mode 100644 app/shared/overnight-calendar/tests/OvernightCalendar.spec.js create mode 100644 app/shared/overnight-calendar/tests/OvernightCalendarSelector.spec.js create mode 100644 app/shared/overnight-calendar/tests/OvernightEditSummary.spec.js create mode 100644 app/shared/overnight-calendar/tests/OvernightHiddenHeading.spec.js create mode 100644 app/shared/overnight-calendar/tests/OvernightLegends.spec.js create mode 100644 app/shared/overnight-calendar/tests/OvernightSummary.spec.js create mode 100644 app/shared/overnight-calendar/tests/overnightUtils.spec.js create mode 100644 app/shared/reservation-date/ReservationOvernightDate.js create mode 100644 app/shared/reservation-date/ReservationOvernightDate.spec.js diff --git a/app/actions/uiActions.js b/app/actions/uiActions.js index b74a67473..06cde2afa 100644 --- a/app/actions/uiActions.js +++ b/app/actions/uiActions.js @@ -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, @@ -163,4 +165,5 @@ export { unselectAdminResourceType, changeContrast, changeFontSize, + setSelectedDatetimes, }; diff --git a/app/constants/ActionTypes.js b/app/constants/ActionTypes.js index cd27ad08d..f9b857a85 100644 --- a/app/constants/ActionTypes.js +++ b/app/constants/ActionTypes.js @@ -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', } }; diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json index de1901b5c..e0594c6b2 100644 --- a/app/i18n/messages/en.json +++ b/app/i18n/messages/en.json @@ -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", @@ -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", diff --git a/app/i18n/messages/fi.json b/app/i18n/messages/fi.json index fc0a6194e..143741e1f 100644 --- a/app/i18n/messages/fi.json +++ b/app/i18n/messages/fi.json @@ -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", @@ -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", diff --git a/app/i18n/messages/sv.json b/app/i18n/messages/sv.json index d7edb8e92..e6fff6b91 100644 --- a/app/i18n/messages/sv.json +++ b/app/i18n/messages/sv.json @@ -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", @@ -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", diff --git a/app/pages/manage-reservations/list/__tests__/listUtils.spec.js b/app/pages/manage-reservations/list/__tests__/listUtils.spec.js index 36f77b1c5..8566b0981 100644 --- a/app/pages/manage-reservations/list/__tests__/listUtils.spec.js +++ b/app/pages/manage-reservations/list/__tests__/listUtils.spec.js @@ -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', () => { diff --git a/app/pages/manage-reservations/list/listUtils.js b/app/pages/manage-reservations/list/listUtils.js index 883e82b7c..5502f4cf8 100644 --- a/app/pages/manage-reservations/list/listUtils.js +++ b/app/pages/manage-reservations/list/listUtils.js @@ -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')}`; } diff --git a/app/pages/reservation/reservation-confirmation/ReservationConfirmation.js b/app/pages/reservation/reservation-confirmation/ReservationConfirmation.js index 395d3ddf6..0dfcdaf77 100644 --- a/app/pages/reservation/reservation-confirmation/ReservationConfirmation.js +++ b/app/pages/reservation/reservation-confirmation/ReservationConfirmation.js @@ -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 = { @@ -132,6 +134,8 @@ class ReservationConfirmation extends Component { email = user.email; } + const reservationIsMultiday = isMultiday(reservation.begin, reservation.end); + return ( @@ -139,7 +143,10 @@ class ReservationConfirmation extends Component {

{t(`ReservationConfirmation.reservation${isEdited ? 'Edited' : 'Created'}Title`)}

- + {reservationIsMultiday + ? + : + }

{ const originalModule = jest.requireActual('../../../shared/quality-tools-form/qualityToolsUtils'); @@ -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', () => { diff --git a/app/pages/reservation/reservation-details/ReservationDetails.js b/app/pages/reservation/reservation-details/ReservationDetails.js index 3cc74a36f..0a78733db 100644 --- a/app/pages/reservation/reservation-details/ReservationDetails.js +++ b/app/pages/reservation/reservation-details/ReservationDetails.js @@ -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 ( diff --git a/app/pages/reservation/reservation-time/ReservationTime.js b/app/pages/reservation/reservation-time/ReservationTime.js index 8063fbfa9..ebbdee4c5 100644 --- a/app/pages/reservation/reservation-time/ReservationTime.js +++ b/app/pages/reservation/reservation-time/ReservationTime.js @@ -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 = { @@ -63,29 +64,43 @@ class ReservationTime extends Component {

{t('ReservationPhase.timeTitle')}

- - -
- - -
+ {resource.overnightReservations ? ( + + ) : ( + + + +
+ + +
+
+ )} { const history = { @@ -39,44 +40,82 @@ describe('pages/reservation/reservation-time/ReservationTime', () => { expect(header.text()).toBe('ReservationPhase.timeTitle'); }); - test('renders ResourceCalendar', () => { - const wrapper = getWrapper(); - const instance = wrapper.instance(); - const resourceCalendar = wrapper.find(ResourceCalendar); - const date = moment(defaultProps.selectedReservation.begin).format('YYYY-MM-DD'); + describe('when reservation is single day', () => { + test('doesnt render OvernightCalendar', () => { + const overnightCalendar = getWrapper().find(OvernightCalendar); + expect(overnightCalendar).toHaveLength(0); + }); - expect(resourceCalendar).toHaveLength(1); - expect(resourceCalendar.prop('onDateChange')).toBe(instance.handleDateChange); - expect(resourceCalendar.prop('selectedDate')).toBe(date); - }); + test('renders ResourceCalendar', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + const resourceCalendar = wrapper.find(ResourceCalendar); + const date = moment(defaultProps.selectedReservation.begin).format('YYYY-MM-DD'); + + expect(resourceCalendar).toHaveLength(1); + expect(resourceCalendar.prop('onDateChange')).toBe(instance.handleDateChange); + expect(resourceCalendar.prop('selectedDate')).toBe(date); + }); - test('renders ReservationCalendar', () => { - const location = { query: { q: 1 } }; - const reservationCalendar = getWrapper({ location }).find(ReservationCalendar); + test('renders ReservationCalendar', () => { + const location = { query: { q: 1 } }; + const reservationCalendar = getWrapper({ location }).find(ReservationCalendar); - expect(reservationCalendar).toHaveLength(1); - expect(reservationCalendar.prop('location')).toEqual(location); - expect(reservationCalendar.prop('params')).toEqual({ id: defaultProps.resource.id }); - }); + expect(reservationCalendar).toHaveLength(1); + expect(reservationCalendar.prop('location')).toEqual(location); + expect(reservationCalendar.prop('params')).toEqual({ id: defaultProps.resource.id }); + }); - test('renders cancel button', () => { - const onCancel = () => undefined; - const wrapper = getWrapper({ onCancel }); - const button = wrapper.find('.cancel_Button'); - expect(button).toHaveLength(1); - expect(button.prop('bsStyle')).toBe('warning'); - expect(button.prop('onClick')).toBe(onCancel); - expect(button.prop('children')).toBe('ReservationInformationForm.cancelEdit'); + test('renders cancel button', () => { + const onCancel = () => undefined; + const wrapper = getWrapper({ onCancel }); + const button = wrapper.find('.cancel_Button'); + expect(button).toHaveLength(1); + expect(button.prop('bsStyle')).toBe('warning'); + expect(button.prop('onClick')).toBe(onCancel); + expect(button.prop('children')).toBe('ReservationInformationForm.cancelEdit'); + }); + + test('renders next button', () => { + const onConfirm = () => undefined; + const wrapper = getWrapper({ onConfirm }); + const button = wrapper.find('.next_Button'); + expect(button).toHaveLength(1); + expect(button.prop('bsStyle')).toBe('primary'); + expect(button.prop('onClick')).toBe(onConfirm); + expect(button.prop('children')).toBe('common.continue'); + }); }); - test('renders next button', () => { - const onConfirm = () => undefined; - const wrapper = getWrapper({ onConfirm }); - const button = wrapper.find('.next_Button'); - expect(button).toHaveLength(1); - expect(button.prop('bsStyle')).toBe('primary'); - expect(button.prop('onClick')).toBe(onConfirm); - expect(button.prop('children')).toBe('common.continue'); + describe('when reservation is overnight', () => { + const resource = Resource.build({ overnightReservations: true }); + const selectedReservation = Reservation.build({ begin: '2018-01-01', end: '2018-01-02' }); + + test('renders OvernightCalendar', () => { + const wrapper = getWrapper({ selectedReservation, resource }); + const instance = wrapper.instance(); + const overnightCalendar = wrapper.find(OvernightCalendar); + + expect(overnightCalendar).toHaveLength(1); + expect(overnightCalendar.prop('handleDateChange')).toBe(instance.handleDateChange); + expect(overnightCalendar.prop('history')).toBe(history); + expect(overnightCalendar.prop('onEditCancel')).toBe(defaultProps.onCancel); + expect(overnightCalendar.prop('onEditConfirm')).toBe(defaultProps.onConfirm); + expect(overnightCalendar.prop('params')).toStrictEqual({ id: resource.id }); + expect(overnightCalendar.prop('reservationId')).toBe(selectedReservation.id); + expect(overnightCalendar.prop('resource')).toBe(resource); + }); + + test('doesnt render ResourceCalendar', () => { + const resourceCalendar = getWrapper({ selectedReservation, resource }).find(ResourceCalendar); + expect(resourceCalendar).toHaveLength(0); + }); + + test('doesnt render ReservationCalendar', () => { + const reservationCalendar = getWrapper({ selectedReservation, resource }) + .find(ResourceCalendar); + expect(reservationCalendar).toHaveLength(0); + }); }); test('renders reservation details', () => { diff --git a/app/pages/resource/ResourcePage.js b/app/pages/resource/ResourcePage.js index 37b6625d9..9c039b6be 100644 --- a/app/pages/resource/ResourcePage.js +++ b/app/pages/resource/ResourcePage.js @@ -36,6 +36,7 @@ import { fetchResourceOutlookCalendarLinks, } from 'resource-outlook-linker/actions'; import NextFreeTimesButton from './next-free-times-button/NextFreeTimesButton'; +import OvernightCalendar from '../../shared/overnight-calendar/OvernightCalendar'; const ResourceMap = lazy(() => import('shared/resource-map')); @@ -332,26 +333,39 @@ class UnconnectedResourcePage extends Component { )} {resource.reservable && this.renderLogin()} - - - + {resource.overnightReservations ? ( + + ) + : ( + + + + + + ) + } )} - diff --git a/app/pages/resource/ResourcePage.spec.js b/app/pages/resource/ResourcePage.spec.js index db786bcb6..167ee4d69 100644 --- a/app/pages/resource/ResourcePage.spec.js +++ b/app/pages/resource/ResourcePage.spec.js @@ -18,6 +18,9 @@ import { UnconnectedResourcePage as ResourcePage } from './ResourcePage'; import ResourceHeader from './resource-header'; import ResourceInfo from './resource-info'; import ResourceMapInfo from './resource-map-info'; +import NextFreeTimesButton from './next-free-times-button/NextFreeTimesButton'; +import ReservationCalendarContainer from './reservation-calendar'; +import OvernightCalendar from '../../shared/overnight-calendar/OvernightCalendar'; describe('pages/resource/ResourcePage', () => { const unit = Unit.build(); @@ -131,13 +134,75 @@ describe('pages/resource/ResourcePage', () => { expect(resourceInfo.prop('currentLanguage')).toBe(defaultProps.currentLanguage); }); - test('renders ResourceCalendar with correct props', () => { - const wrapper = getWrapper(); - const calendar = wrapper.find(ResourceCalendar); - expect(calendar).toHaveLength(1); - expect(calendar.prop('onDateChange')).toBe(wrapper.instance().handleDateChange); - expect(calendar.prop('resourceId')).toBe(defaultProps.resource.id); - expect(calendar.prop('selectedDate')).toBe(defaultProps.date); + describe('when resource overnightReservations is false', () => { + test('renders ResourceCalendar with correct props', () => { + const wrapper = getWrapper(); + const calendar = wrapper.find(ResourceCalendar); + expect(calendar).toHaveLength(1); + expect(calendar.prop('onDateChange')).toBe(wrapper.instance().handleDateChange); + expect(calendar.prop('resourceId')).toBe(defaultProps.resource.id); + expect(calendar.prop('selectedDate')).toBe(defaultProps.date); + }); + + test('renders NextFreeTimesButton', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + const btn = wrapper.find(NextFreeTimesButton); + expect(btn).toHaveLength(1); + expect(btn.prop('addNotification')).toBe(defaultProps.actions.addNotification); + expect(btn.prop('handleDateChange')).toBe(instance.handleDateChange); + expect(btn.prop('resource')).toBe(defaultProps.resource); + expect(btn.prop('selectedDate')).toBe(defaultProps.date); + }); + + test('renders ReservationCalendar', () => { + const wrapper = getWrapper(); + const calendar = wrapper.find(ReservationCalendarContainer); + expect(calendar).toHaveLength(1); + expect(calendar.prop('history')).toBe(defaultProps.history); + expect(calendar.prop('location')).toBe(defaultProps.location); + expect(calendar.prop('params')).toBe(defaultProps.match.params); + }); + + test('doesnt render OvernightCalendar', () => { + const wrapper = getWrapper(); + const calendar = wrapper.find(OvernightCalendar); + expect(calendar).toHaveLength(0); + }); + }); + + describe('when resource overnightReservations is true', () => { + const resourceA = { ...defaultProps.resource, overnightReservations: true }; + + test('renders OvernightCalendar', () => { + const wrapper = getWrapper({ resource: resourceA }); + const instance = wrapper.instance(); + const calendar = wrapper.find(OvernightCalendar); + expect(calendar).toHaveLength(1); + expect(calendar.prop('handleDateChange')).toBe(instance.handleDateChange); + expect(calendar.prop('history')).toBe(defaultProps.history); + expect(calendar.prop('params')).toBe(defaultProps.match.params); + expect(calendar.prop('resource')).toBe(resourceA); + expect(calendar.prop('selectedDate')).toBe(defaultProps.date); + }); + + test('doesnt render ResourceCalendar', () => { + const wrapper = getWrapper({ resource: resourceA }); + const calendar = wrapper.find(ResourceCalendar); + expect(calendar).toHaveLength(0); + }); + + test('doesnt render NextFreeTimesButton', () => { + const wrapper = getWrapper({ resource: resourceA }); + const btn = wrapper.find(NextFreeTimesButton); + expect(btn).toHaveLength(0); + }); + + test('doesnt render ReservationCalendar', () => { + const wrapper = getWrapper({ resource: resourceA }); + const calendar = wrapper.find(ReservationCalendarContainer); + expect(calendar).toHaveLength(0); + }); }); test('renders resource images with thumbnail urls', () => { diff --git a/app/pages/user-reservations/reservation-list/ReservationListItem.js b/app/pages/user-reservations/reservation-list/ReservationListItem.js index 2bf024281..860cb71df 100644 --- a/app/pages/user-reservations/reservation-list/ReservationListItem.js +++ b/app/pages/user-reservations/reservation-list/ReservationListItem.js @@ -12,6 +12,7 @@ import TimeRange from 'shared/time-range'; import { injectT } from 'i18n'; import { getMainImage } from 'utils/imageUtils'; import { getResourcePageUrl } from 'utils/resourceUtils'; +import { isMultiday } from '../../../utils/timeUtils'; class ReservationListItem extends Component { renderImage(image) { @@ -27,6 +28,9 @@ class ReservationListItem extends Component { } = this.props; const nameSeparator = isEmpty(resource) || isEmpty(unit) ? '' : ', '; + const isReservationMultiday = isMultiday(reservation.begin, reservation.end); + const beginFormat = isReservationMultiday ? 'D.M.YYYY HH:mm' : 'dddd, LLL'; + const endFormat = isReservationMultiday ? 'D.M.YYYY HH:mm' : 'LT'; return (
  • @@ -52,7 +56,12 @@ class ReservationListItem extends Component {
    {t('common.reservationTimeLabel')} - +
    { expect(timeslotIcon.prop('src')).toBeDefined(); }); - test('contains TimeRange component with correct props', () => { - const timeRange = component.find(TimeRange); - expect(timeRange).toHaveLength(1); - expect(timeRange.prop('begin')).toBe(props.reservation.begin); - expect(timeRange.prop('end')).toBe(props.reservation.end); + describe('when reservation is not multiday', () => { + test('contains TimeRange component with correct props', () => { + const timeRange = component.find(TimeRange); + expect(timeRange).toHaveLength(1); + expect(timeRange.prop('begin')).toBe(props.reservation.begin); + expect(timeRange.prop('end')).toBe(props.reservation.end); + expect(timeRange.prop('beginFormat')).toBe('dddd, LLL'); + expect(timeRange.prop('endFormat')).toBe('LT'); + }); + }); + + describe('when reservation is multiday', () => { + test('contains TimeRange component with correct props', () => { + const reservationA = { ...props.reservation, begin: '2017-01-01T10:00:00+02:00', end: '2017-01-02T12:00:00+02:00' }; + const newProps = { ...props, reservation: reservationA }; + const componentA = shallowWithIntl(); + const timeRange = componentA.find(TimeRange); + expect(timeRange).toHaveLength(1); + expect(timeRange.prop('begin')).toBe(reservationA.begin); + expect(timeRange.prop('end')).toBe(reservationA.end); + expect(timeRange.prop('beginFormat')).toBe('D.M.YYYY HH:mm'); + expect(timeRange.prop('endFormat')).toBe('D.M.YYYY HH:mm'); + }); }); test('renders ReservationStateLabel component', () => { diff --git a/app/shared/_shared.scss b/app/shared/_shared.scss index 4fff58d2c..fe1647efd 100644 --- a/app/shared/_shared.scss +++ b/app/shared/_shared.scss @@ -39,3 +39,4 @@ @import './top-navbar/login-logout-controls/login-logout-controls'; @import './top-navbar/language-dropdown/language-dropdown'; @import './quality-tools-form/quality-tools-form'; +@import './overnight-calendar/overnight-calendar'; \ No newline at end of file diff --git a/app/shared/compact-reservation-list/CompactReservationList.js b/app/shared/compact-reservation-list/CompactReservationList.js index 09204dc1a..ca3d288b6 100644 --- a/app/shared/compact-reservation-list/CompactReservationList.js +++ b/app/shared/compact-reservation-list/CompactReservationList.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import Glyphicon from 'react-bootstrap/lib/Glyphicon'; import TimeRange from 'shared/time-range'; +import { isMultiday } from '../../utils/timeUtils'; class CompactReservationList extends Component { renderFixedReservation = reservation => this.renderReservation(reservation); @@ -15,10 +16,20 @@ class CompactReservationList extends Component { const resource = this.props.resources[reservation.resource] || {}; resourceName = resource.name; } + + const isReservationMultiday = isMultiday(reservation.begin, reservation.end); + const beginFormat = isReservationMultiday ? 'D.M.YYYY HH:mm' : 'dddd, LLL'; + const endFormat = isReservationMultiday ? 'D.M.YYYY HH:mm' : 'LT'; + return (
  • {resourceName ? `${resourceName}: ` : ''} - + {removable && ( @@ -179,7 +184,14 @@ class UnconnectedReservationEditForm extends Component { ); } - const staticReservationTime = ; + const staticReservationTime = ( + + ); return this.renderInfoRow(t('common.reservationTimeLabel'), staticReservationTime); } diff --git a/app/shared/modals/reservation-info/ReservationEditForm.spec.js b/app/shared/modals/reservation-info/ReservationEditForm.spec.js index d914d80d3..e8e9e3adb 100644 --- a/app/shared/modals/reservation-info/ReservationEditForm.spec.js +++ b/app/shared/modals/reservation-info/ReservationEditForm.spec.js @@ -419,6 +419,14 @@ describe('shared/modals/reservation-info/ReservationEditForm', () => { .filter({ names: ['begin', 'end'] }); expect(timeControls).toHaveLength(1); }); + + test('doesnt render ReservationTimeControls when reservation is multiday', () => { + const reservationA = { ...defaultProps.reservation, begin: '2023-11-20T15:00:00', end: '2023-11-21T15:00:00' }; + const timeControls = getWrapper({ isEditing: true, reservation: reservationA }) + .find(Fields) + .filter({ names: ['begin', 'end'] }); + expect(timeControls).toHaveLength(0); + }); }); describe('when not editing', () => { diff --git a/app/shared/overnight-calendar/OvernightCalendar.js b/app/shared/overnight-calendar/OvernightCalendar.js new file mode 100644 index 000000000..5a20466a9 --- /dev/null +++ b/app/shared/overnight-calendar/OvernightCalendar.js @@ -0,0 +1,304 @@ +import React, { useEffect } from 'react'; +import DayPicker from 'react-day-picker'; +import MomentLocaleUtils from 'react-day-picker/moment'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { injectT } from 'i18n'; +import { + areDatesSameAsInitialDates, + closedDaysModifier, + filterSelectedReservation, + getNotSelectableNotificationText, + getNotificationText, + getOvernightDatetime, + getReservationUrl, + getSelectedDuration, + handleDateSelect, + handleDisableDays, + handleFormattingSelected, + isDurationBelowMin, + isReservingAllowed, + nextDayBookedModifier, + nextDayClosedModifier, + prevDayBookedModifier, + prevDayClosedModifier, + reservationsModifier +} from './overnightUtils'; +import OvernightCalendarSelector from './OvernightCalendarSelector'; +import OvernightSummary from './OvernightSummary'; +import { setSelectedDatetimes } from '../../actions/uiActions'; +import OvernightLegends from './OvernightLegends'; +import { addNotification } from 'actions/notificationsActions'; +import OvernightEditSummary from './OvernightEditSummary'; +import { getPrettifiedPeriodUnits } from '../../utils/timeUtils'; +import { hasMaxReservations } from '../../utils/resourceUtils'; +import OvernightHiddenHeading from './OvernightHiddenHeading'; + +function OvernightCalendar({ + currentLanguage, resource, t, selected, actions, isStaff, + history, isLoggedIn, isStrongAuthSatisfied, isMaintenanceModeOn, + reservationId, onEditCancel, onEditConfirm, handleDateChange, selectedDate, + isSuperuser, +}) { + if (!resource || !resource.reservations) { + return null; + } + + let initialStart = null; + let initialEnd = null; + + if (selected && selected.length > 1) { + initialStart = moment(selected[0].begin).toDate(); + initialEnd = moment(selected[1].end).toDate(); + } + + // for init month use redux's selected > url date > current date + const initialMonth = initialStart || moment(selectedDate).toDate() || new Date(); + + const [startDate, setStartDate] = React.useState(initialStart); + const [endDate, setEndDate] = React.useState(initialEnd); + const [currentMonth, setCurrentMonth] = React.useState(initialMonth); + + const datesSameAsInitial = areDatesSameAsInitialDates( + startDate, endDate, initialStart, initialEnd); + + const { + reservable, reservableAfter, reservableBefore, openingHours, reservations, + overnightStartTime, overnightEndTime, maxPeriod, minPeriod + } = resource; + + const hasAdminBypass = isSuperuser || isStaff; + + useEffect(() => { + if (!hasAdminBypass && startDate && endDate && !datesSameAsInitial) { + const selectedDuration = getSelectedDuration( + startDate, endDate, overnightStartTime, overnightEndTime); + const isDurBelowMin = isDurationBelowMin(selectedDuration, minPeriod); + const minDurationText = getPrettifiedPeriodUnits(minPeriod, t('common.unit.time.day.short')); + if (isDurBelowMin) { + actions.addNotification({ + message: `${t('Overnight.belowMinAlert')} (${minDurationText})`, + type: 'info', + timeOut: 10000, + }); + } + } + }, [startDate, endDate]); + + const filteredReservations = reservationId + ? filterSelectedReservation(reservationId, reservations) : reservations; + + const highlighted = { from: startDate, to: endDate }; + const available = { from: new Date(2024, 2, 4), to: new Date(2024, 2, 8) }; + const start = startDate; + const end = endDate; + + const now = moment(); + const reservingIsAllowed = isReservingAllowed({ + isLoggedIn, isStrongAuthSatisfied, isMaintenanceModeOn, resource, hasAdminBypass + }); + + const validateAndSelect = (day, { booked, nextBooked, nextClosed }) => { + const isNextBlocked = !startDate && (nextBooked || nextClosed); + const isDateDisabled = handleDisableDays({ + day, + now, + reservable, + reservableAfter, + reservableBefore, + startDate, + openingHours, + reservations: filteredReservations, + maxPeriod, + minPeriod, + overnightEndTime, + overnightStartTime, + hasAdminBypass, + }); + + if (!reservingIsAllowed) { + actions.addNotification({ + message: getNotificationText({ + isLoggedIn, isStrongAuthSatisfied, isMaintenanceModeOn, resource, t + }), + type: 'info', + timeOut: 10000, + }); + return; + } + + if (!isDateDisabled && !booked && !isNextBlocked) { + handleDateSelect({ + value: day, + startDate, + setStartDate, + endDate, + setEndDate, + overnightStartTime, + overnightEndTime + }); + return; + } + + actions.addNotification({ + message: getNotSelectableNotificationText({ + isDateDisabled, booked, isNextBlocked, t + }), + type: 'info', + timeOut: 10000, + }); + }; + + const isEditing = !!initialStart; + + const handleSelectDatetimes = () => { + if (!hasAdminBypass && hasMaxReservations(resource) && !isEditing) { + actions.addNotification({ + message: t('TimeSlots.maxReservationsPerUser'), + type: 'error', + timeOut: 10000, + }); + return; + } + const formattedSelected = handleFormattingSelected( + startDate, endDate, overnightStartTime, overnightEndTime, resource.id); + actions.setSelectedDatetimes([formattedSelected, formattedSelected]); + if (isEditing) { + onEditConfirm(); + } else { + const nextUrl = getReservationUrl(undefined, resource.id); + history.push(nextUrl); + } + }; + + const selectedDuration = getSelectedDuration( + startDate, endDate, overnightStartTime, overnightEndTime); + const isDurBelowMin = hasAdminBypass ? false : isDurationBelowMin(selectedDuration, minPeriod); + + return ( +
    + + handleDisableDays({ + day, + now, + reservable, + reservableAfter, + reservableBefore, + startDate, + openingHours, + reservations: filteredReservations, + maxPeriod, + minPeriod, + overnightEndTime, + overnightStartTime, + hasAdminBypass, + })} + enableOutsideDays + firstDayOfWeek={1} + initialMonth={initialMonth} + labels={{ previousMonth: t('Overnight.prevMonth'), nextMonth: t('Overnight.nextMonth') }} + locale={currentLanguage} + localeUtils={MomentLocaleUtils} + modifiers={{ + start, + end, + highlighted, + available, + closed: (day) => closedDaysModifier(day, openingHours), + booked: (day) => ( + startDate ? null : reservationsModifier(day, filteredReservations)), + nextBooked: (day) => ( + startDate ? null : nextDayBookedModifier(day, filteredReservations)), + nextBookedStartSelected: (day) => ( + startDate ? nextDayBookedModifier(day, filteredReservations) : null), + nextClosed: (day) => nextDayClosedModifier(day, openingHours), + prevBooked: (day) => ( + startDate ? null : prevDayBookedModifier(day, filteredReservations)), + prevClosed: (day) => prevDayClosedModifier(day, openingHours), + }} + onDayClick={validateAndSelect} + onMonthChange={(date) => { handleDateChange(date); setCurrentMonth(date); }} + selectedDays={[startDate, endDate]} + showOutsideDays + todayButton={t('Overnight.currentMonth')} + /> + + {!isEditing && ( + + )} + {isEditing && ( + + )} +
    + ); +} + +OvernightCalendar.defaultProps = { + reservationId: 0, + selectedDate: '', +}; + +OvernightCalendar.propTypes = { + currentLanguage: PropTypes.string.isRequired, + resource: PropTypes.object.isRequired, + t: PropTypes.func.isRequired, + selected: PropTypes.array.isRequired, + actions: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + isStaff: PropTypes.bool.isRequired, + isSuperuser: PropTypes.bool.isRequired, + isLoggedIn: PropTypes.bool.isRequired, + isStrongAuthSatisfied: PropTypes.bool.isRequired, + isMaintenanceModeOn: PropTypes.bool.isRequired, + reservationId: PropTypes.number, + onEditCancel: PropTypes.func, + onEditConfirm: PropTypes.func, + handleDateChange: PropTypes.func.isRequired, + selectedDate: PropTypes.string, + params: PropTypes.object.isRequired, // eslint-disable-line +}; + +OvernightCalendar = injectT(OvernightCalendar); // eslint-disable-line +export { OvernightCalendar as UnconnectedOvernightCalendar }; + + +function mapDispatchToProps(dispatch) { + const actionCreators = { + setSelectedDatetimes, + addNotification, + }; + + return { actions: bindActionCreators(actionCreators, dispatch) }; +} + +export default connect( + OvernightCalendarSelector, + mapDispatchToProps, +)(OvernightCalendar); + diff --git a/app/shared/overnight-calendar/OvernightCalendarSelector.js b/app/shared/overnight-calendar/OvernightCalendarSelector.js new file mode 100644 index 000000000..b03c46442 --- /dev/null +++ b/app/shared/overnight-calendar/OvernightCalendarSelector.js @@ -0,0 +1,24 @@ +import { createStructuredSelector } from 'reselect'; + +import { currentLanguageSelector } from 'state/selectors/translationSelectors'; +import { + isLoggedInSelector, createIsStaffSelector, isSuperUserSelector +} from 'state/selectors/authSelectors'; +import { createResourceSelector, createStrongAuthSatisfiedSelector } from 'state/selectors/dataSelectors'; + +const resourceIdSelector = (state, props) => props.params.id; +const resourceSelector = createResourceSelector(resourceIdSelector); +const selectedSelector = state => state.ui.reservations.selected; +const isMaintenanceModeOnSelector = state => state.ui.maintenance.isMaintenanceModeOn; + +const OvernightCalendarSelector = createStructuredSelector({ + selected: selectedSelector, + isMaintenanceModeOn: isMaintenanceModeOnSelector, + isStrongAuthSatisfied: createStrongAuthSatisfiedSelector(resourceSelector), + currentLanguage: currentLanguageSelector, + isLoggedIn: isLoggedInSelector, + isStaff: createIsStaffSelector(resourceSelector), + isSuperuser: isSuperUserSelector, +}); + +export default OvernightCalendarSelector; diff --git a/app/shared/overnight-calendar/OvernightEditSummary.js b/app/shared/overnight-calendar/OvernightEditSummary.js new file mode 100644 index 000000000..af3f4340f --- /dev/null +++ b/app/shared/overnight-calendar/OvernightEditSummary.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'react-bootstrap/lib/Button'; + +import injectT from '../../i18n/injectT'; +import { getPrettifiedPeriodUnits } from '../../utils/timeUtils'; + + +function OvernightEditSummary({ + startDatetime, endDatetime, selected, onCancel, onConfirm, t, + duration, minDuration, isDurationBelowMin, datesSameAsInitial +}) { + const timeRange = startDatetime && endDatetime ? `${startDatetime} - ${endDatetime}` : `${selected[0]} - ${selected[1]}`; + const durationText = getPrettifiedPeriodUnits(duration, t('common.unit.time.day.short')); + const minDurationText = getPrettifiedPeriodUnits(minDuration, t('common.unit.time.day.short')); + const hasMinDurationError = !datesSameAsInitial && isDurationBelowMin; + const validRange = startDatetime && endDatetime; + + return ( +
    +
    +
    + {validRange && ( + + + {`${t('TimeSlots.selectedDate')} `} + + {`${timeRange} (${durationText})`} + + )} +
    + {hasMinDurationError && ( +

    {`${t('Overnight.belowMinAlert')} (${minDurationText})`}

    + )} +
    +
    + + +
    +
    + ); +} + +OvernightEditSummary.propTypes = { + t: PropTypes.func.isRequired, + selected: PropTypes.array.isRequired, + endDatetime: PropTypes.string.isRequired, + startDatetime: PropTypes.string.isRequired, + onCancel: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + duration: PropTypes.object.isRequired, + isDurationBelowMin: PropTypes.bool.isRequired, + minDuration: PropTypes.string.isRequired, + datesSameAsInitial: PropTypes.bool.isRequired, +}; + +export default injectT(OvernightEditSummary); diff --git a/app/shared/overnight-calendar/OvernightHiddenHeading.js b/app/shared/overnight-calendar/OvernightHiddenHeading.js new file mode 100644 index 000000000..b0058a5d9 --- /dev/null +++ b/app/shared/overnight-calendar/OvernightHiddenHeading.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import injectT from '../../i18n/injectT'; + +function OvernightHiddenHeading({ + date, localeUtils, locale, t +}) { + const dateText = localeUtils.formatMonthTitle(date, locale); + return ( +
    +

    {t('Overnight.calendar')}

    +

    {dateText}

    +
    + ); +} + +OvernightHiddenHeading.propTypes = { + date: PropTypes.instanceOf(Date).isRequired, + localeUtils: PropTypes.object.isRequired, + locale: PropTypes.string.isRequired, + t: PropTypes.func.isRequired, +}; + +export default injectT(OvernightHiddenHeading); diff --git a/app/shared/overnight-calendar/OvernightLegends.js b/app/shared/overnight-calendar/OvernightLegends.js new file mode 100644 index 000000000..57ce30058 --- /dev/null +++ b/app/shared/overnight-calendar/OvernightLegends.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import injectT from '../../i18n/injectT'; + + +function OvernightLegends({ t }) { + return ( +
    +
    +
    +
    21
    + {t('Overnight.legend.available')} +
    +
    +
    21
    + {t('Overnight.legend.notSelectable')} +
    +
    +
    +
    +
    21
    + {t('Overnight.legend.reserved')} +
    +
    +
    21
    + {t('Overnight.legend.ownSelection')} +
    +
    +
    + ); +} + +OvernightLegends.propTypes = { + t: PropTypes.func.isRequired, +}; + +export default injectT(OvernightLegends); diff --git a/app/shared/overnight-calendar/OvernightSummary.js b/app/shared/overnight-calendar/OvernightSummary.js new file mode 100644 index 000000000..fadf438a2 --- /dev/null +++ b/app/shared/overnight-calendar/OvernightSummary.js @@ -0,0 +1,60 @@ +import React from 'react'; +import Button from 'react-bootstrap/lib/Button'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import injectT from '../../i18n/injectT'; +import { getPrettifiedPeriodUnits } from '../../utils/timeUtils'; + +function OvernightSummary({ + t, selected, endDatetime, startDatetime, handleSelectDatetimes, + duration, isDurationBelowMin, minDuration +}) { + const timeRange = startDatetime && endDatetime ? `${startDatetime} - ${endDatetime}` : `${selected[0]} - ${selected[1]}`; + const durationText = getPrettifiedPeriodUnits(duration, t('common.unit.time.day.short')); + const minDurationText = getPrettifiedPeriodUnits(minDuration, t('common.unit.time.day.short')); + const validRange = startDatetime && endDatetime; + + return ( +
    +

    {t('ReservationCalendar.Confirmation.header')}

    +
    +
    + {validRange && ( + + + {`${t('TimeSlots.selectedDate')} `} + + {`${timeRange} (${durationText})`} + + )} +
    + {isDurationBelowMin && ( +

    {`${t('Overnight.belowMinAlert')} (${minDurationText})`}

    + )} +
    +
    + +
    +
    + ); +} + +OvernightSummary.propTypes = { + t: PropTypes.func.isRequired, + selected: PropTypes.array.isRequired, + endDatetime: PropTypes.string.isRequired, + startDatetime: PropTypes.string.isRequired, + handleSelectDatetimes: PropTypes.func.isRequired, + duration: PropTypes.object.isRequired, + isDurationBelowMin: PropTypes.bool.isRequired, + minDuration: PropTypes.string.isRequired, +}; + +export default injectT(OvernightSummary); diff --git a/app/shared/overnight-calendar/_overnight-calendar.scss b/app/shared/overnight-calendar/_overnight-calendar.scss new file mode 100644 index 000000000..2a9f8459b --- /dev/null +++ b/app/shared/overnight-calendar/_overnight-calendar.scss @@ -0,0 +1,367 @@ +.overnight-calendar { + $available-color: $light-gray; + $busy-color: $yellow; + $booked-color: $red; + $available-color-green: #00d7a7; + $highlighted-color: #00ccff; + $half-day-angle: 140deg; + + .overnight-error { + color: $varaamo-high-contrast-red; + margin-top: 0; + margin-bottom: 12px; + } + + .DayPicker { + @media (max-width: $screen-sm-min) { + width: 100%; + } + } + + .DayPicker-NavBar { + position: absolute; + display: flex; + width: 100%; + justify-content: space-between; + margin: 12px 0; + } + + .DayPicker-NavButton--prev { + @include icon-angle-left($black); + width: $line-height-computed; + height: $line-height-computed; + position: static; + margin-left: 5px; + + @include add-focus(2px, $black) {} + } + + .DayPicker-NavButton--next { + @include icon-angle-right($black); + width: $line-height-computed; + height: $line-height-computed; + position: static; + margin-right: 5px; + + @include add-focus(2px, $black) {} + } + + .DayPicker-Caption { + height: auto; + font-weight: 600; + line-height: 3rem; + font-size: 16px; + text-transform: capitalize; + text-align: center; + } + + .DayPicker-Month { + margin: 0; + border-collapse: separate; + width: 100%; + } + + .DayPicker-Day { + border: 2px solid $white; + padding: 1.2rem; + margin: 1px; + line-height: 1rem; + color: $black; + background-color: $available-color; + border-radius: 0; + font-size: 1.6rem; + + @media (max-width: $screen-xs-max) { + padding: 19.2px 0px; + } + + &:focus { + outline: initial; + } + + &--disabled { + cursor: pointer; + text-decoration: line-through; + background-color: $medium-gray; + color: $dark-gray; + } + + &--closed { + cursor: pointer; + text-decoration: line-through; + background: initial !important; + background-color: $medium-gray !important; + color: $dark-gray !important; + } + + &--available:not(.DayPicker-Day--disabled) { + background-color: $available-color-green; + + &.DayPicker-Day--selected { + background-color: $available-color; + } + } + + &--busy:not(.DayPicker-Day--disabled) { + background-color: $busy-color; + + &.DayPicker-Day--selected { + background-color: $busy-color; + } + } + + &--booked { + color: $black; + background-color: $booked-color; + text-decoration: line-through; + + &.DayPicker-Day--selected { + background-color: $booked-color; + } + + &:hover { + background-color: $booked-color !important; + } + } + + &--highlighted:not(.DayPicker-Day--disabled) { + background-color: $highlighted-color; + + &.DayPicker-Day--selected { + background-color: $highlighted-color; + } + + &:hover { + background-color: $highlighted-color !important; + } + } + + &--selected:not(.DayPicker-Day--disabled) { + color: $black; + border: 2px solid $black; + } + + &--start:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $medium-gray 49%, $highlighted-color 50%); + + &.DayPicker-Day--prevBooked { + background: linear-gradient($half-day-angle, $medium-gray 49%, $highlighted-color 50%); + } + + &.DayPicker-Day--prevClosed { + background: linear-gradient($half-day-angle, $medium-gray 49%, $highlighted-color 50%); + } + } + + &--end:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $highlighted-color 49%, $available-color 50%); + + &.DayPicker-Day--nextBookedStartSelected { + background: linear-gradient($half-day-angle, $highlighted-color 49%, $medium-gray 50%); + } + + &.DayPicker-Day--nextClosed { + background: linear-gradient($half-day-angle, $highlighted-color 49%, $medium-gray 50%); + } + } + + &--nextBooked:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $available-color 49%, $booked-color 50%); + } + + &--nextBookedStartSelected:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $available-color 49%, $medium-gray 50%); + } + + &--prevBooked:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $booked-color 49%, $available-color 50%); + } + + &--nextClosed:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $available-color 49%, $medium-gray 50%); + } + + &--prevClosed:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $medium-gray 49%, $available-color 50%); + } + + @include add-focus(2px, $black) {} + } + + .DayPicker-Day--nextClosed.DayPicker-Day--prevBooked { + background: linear-gradient($half-day-angle, $booked-color 49%, $medium-gray 50%); + color: $black; + } + + .DayPicker-Day--nextBooked.DayPicker-Day--prevClosed:not(.DayPicker-Day--disabled) { + background: linear-gradient($half-day-angle, $medium-gray 49%, $booked-color 50%); + } + + .DayPicker-Day--booked.DayPicker-Day--nextClosed { + background: linear-gradient($half-day-angle, $booked-color 49%, $medium-gray 50%); + } + + .DayPicker-Day--nextBooked.DayPicker-Day--disabled:not(.DayPicker-Day--booked) { + background: linear-gradient($half-day-angle, $medium-gray 49%, $booked-color 50%); + color: $black; + } + + .DayPicker-Day--prevBooked.DayPicker-Day--disabled:not(.DayPicker-Day--booked) { + background: linear-gradient($half-day-angle, $booked-color 49%, $medium-gray 50%); + color: $black; + } + + .DayPicker-Day--booked.DayPicker-Day--prevClosed { + background: linear-gradient($half-day-angle, $medium-gray 49%, $booked-color 50%); + } + + .DayPicker-Day--nextBooked.DayPicker-Day--prevBooked:not(.DayPicker-Day--closed) { + background: initial; + background-color: $booked-color; + text-decoration: line-through; + + &:hover { + background-color: $booked-color !important; + } + } + + .DayPicker-Day--nextBooked.DayPicker-Day--prevBooked.DayPicker-Day--disabled:not(.DayPicker-Day--closed) { + background: initial; + background-color: $booked-color; + text-decoration: line-through; + + &:hover { + background-color: $booked-color !important; + } + } + + .DayPicker-Day--prevBooked.DayPicker-Day--booked:not(.DayPicker-Day--disabled) { + background: initial; + background-color: $booked-color; + text-decoration: line-through; + + &:hover { + background-color: $booked-color !important; + } + } + + .DayPicker-Day--nextBooked.DayPicker-Day--booked:not(.DayPicker-Day--disabled) { + background: initial; + background-color: $booked-color; + text-decoration: line-through; + + &:hover { + background-color: $booked-color !important; + } + } + + .DayPicker-Day--nextClosed.DayPicker-Day--prevClosed:not(.DayPicker-Day--disabled) { + background: initial; + background-color: $medium-gray; + text-decoration: line-through; + color: #525a65; + + &:hover { + background-color: $medium-gray !important; + } + } + + .DayPicker-WeekdaysRow { + padding: 0.2rem; + font-size: 1.4rem; + } + + .DayPicker-Weekdays { + text-transform: uppercase; + font-weight: normal; + + abbr { + border: none; + cursor: default; + vertical-align: text-top; + } + } + + .DayPicker-Weekday { + color: $black; + padding: 0px; + } + + .DayPicker-Footer { + text-align: center; + } + + .DayPicker-TodayButton { + text-decoration: none; + color: #000000; + font-weight: 500; + background-color: transparent; + border: 2px solid #000000; + padding: 6px 12px; + font-size: 16px; + line-height: 1.5; + border-radius: 0; + } + + .overnight-legends { + display: flex; + margin-bottom: 24px; + + .overnight-legend { + display: flex; + margin-bottom: 8px; + + .overnight-row { + display: flex; + flex: 1; + margin-right: 24px; + } + + .overnight-legend-box { + width: 60px; + height: 55px; + padding: 1.2rem; + line-height: 1rem; + color: #000000; + border-radius: 0; + font-size: 1.3rem; + vertical-align: middle; + text-align: center; + color: #000000; + } + + .overnight-free { + background-color: $light-gray; + } + + .overnight-disabled { + color: #525a65; + background-color: $medium-gray; + text-decoration: line-through; + } + + .overnight-booked { + background-color: $booked-color; + text-decoration: line-through; + } + + .overnight-selection { + background-color: $highlighted-color; + } + + .overnight-legend-text { + align-self: center; + font-size: 1.2rem; + padding: 0 8px; + } + } + } + + .overnight-edit-summary { + text-align: right; + + .overnight-edit-time-range { + margin-bottom: 8px; + } + } +} diff --git a/app/shared/overnight-calendar/overnightUtils.js b/app/shared/overnight-calendar/overnightUtils.js new file mode 100644 index 000000000..90388b7f3 --- /dev/null +++ b/app/shared/overnight-calendar/overnightUtils.js @@ -0,0 +1,522 @@ +import moment from 'moment'; + +/** + * Handles setting the start and end dates when selecting a date range. + * @param {Object} params + * @param {Date} params.value new date to set + * @param {Date|null} params.startDate starting date or null + * @param {function} params.setStartDate function to set start date + * @param {Date|null} params.endDate ending date or null + * @param {function} params.setEndDate function to set end date + * @param {string} params.overnightStartTime time to set start date to + * @param {string} params.overnightEndTime time to set end date to + */ +export function handleDateSelect({ + value, startDate, setStartDate, endDate, setEndDate, overnightStartTime, overnightEndTime +}) { + if (!value) { + return; + } + + const startTimedValue = setDatesTime(value, overnightStartTime).toDate(); + const endTimedValue = setDatesTime(value, overnightEndTime).toDate(); + + if (!startDate) { + setStartDate(startTimedValue); + } else if (startTimedValue.getTime() === startDate.getTime()) { + setStartDate(null); + setEndDate(null); + } else if (!endDate) { + setEndDate(endTimedValue); + } else if (endTimedValue.getTime() === endDate.getTime()) { + setStartDate(null); + setEndDate(null); + } +} + +/** + * Handles disabling days. + * @param {Object} params + * @param {Date} params.day + * @param {moment} params.now + * @param {boolean} params.reservable + * @param {string} params.reservableAfter datetime + * @param {string} params.reservableBefore datetime + * @param {Date} params.startDate + * @param {Object[]} params.openingHours + * @param {Object[]} params.reservations + * @param {string} params.maxPeriod + * @param {string} params.overnightEndTime + * @param {string} params.overnightStartTime + * @param {boolean} params.hasAdminBypass + * @returns {boolean} is day disabled + */ +export function handleDisableDays({ + day, now, reservable, reservableAfter, reservableBefore, startDate, + openingHours, reservations, maxPeriod, overnightEndTime, + overnightStartTime, hasAdminBypass +}) { + const isAfterToday = now.isAfter(day, 'day'); + const beforeDate = reservableAfter || moment(); + const isBeforeDate = moment(day).isBefore(beforeDate, 'day'); + const afterDate = reservableBefore || moment().add(1, 'year'); + const isAfterDate = moment(day).isAfter(afterDate, 'day'); + const isBeforeStartDate = startDate && moment(day).isBefore(startDate, 'day'); + if (!hasAdminBypass && !reservable) { + return true; + } + if (isAfterToday || isBeforeDate || isAfterDate || isBeforeStartDate) { + return true; + } + if (reservationsModifier(day, reservations)) { + return true; + } + + const closedDays = getClosedDays(openingHours); + for (let index = 0; index < closedDays.length; index += 1) { + const closedDay = closedDays[index]; + if (moment(day).isSame(closedDay.date, 'day')) { + return true; + } + } + + if (startDate) { + if (!hasAdminBypass && maxPeriod && isOverMaxPeriod( + startDate, day, maxPeriod, overnightEndTime, overnightStartTime)) { + return true; + } + + const firstBlockedDay = getFirstBlockedDay(startDate, reservations, closedDays); + if (firstBlockedDay && moment(day).isSameOrAfter(firstBlockedDay, 'day')) { + return true; + } + } + + return false; +} + +/** + * Checks if period is over max period. + * @param {Date} startDate + * @param {Date} endDate + * @param {string} maxPeriod + * @param {string} overnightEndTime + * @param {string} overnightStartTime + * @returns {boolean} is period over max period + */ +export function isOverMaxPeriod( + startDate, endDate, maxPeriod, overnightEndTime, overnightStartTime) { + const end = setDatesTime(endDate, overnightEndTime); + const start = setDatesTime(startDate, overnightStartTime); + const duration = moment.duration(end.diff(start)); + if (duration > moment.duration(maxPeriod)) { + return true; + } + return false; +} + +/** + * Gets closed days in opening hours list + * @param {Object[]} openingHours + * @returns {Object[]} closed days + */ +export function getClosedDays(openingHours) { + return openingHours.filter(oh => !oh.closes || !oh.opens); +} + +/** + * Returns reservations modifier for DayPicker + * @param {Date} day + * @param {Object[]} reservations + * @returns {boolean} is day booked + */ +export function reservationsModifier(day, reservations) { + if (day && reservations) { + for (let index = 0; index < reservations.length; index += 1) { + const reservation = reservations[index]; + const dayMoment = moment(day); + const beginMoment = moment(reservation.begin); + const endMoment = moment(reservation.end); + if (dayMoment.isBetween(beginMoment, endMoment, 'day', '[]')) { + return true; + } + } + } + + return false; +} + +/** + * Modifier for DayPicker that checks if the next day is booked. + * @param {Date} day + * @param {Object[]} reservations + * @returns {boolean} is next day booked + */ +export function nextDayBookedModifier(day, reservations) { + if (day && reservations) { + const firstBooked = findFirstClosestReservation(day, reservations); + if (firstBooked && moment(day).add(1, 'day').isSame(firstBooked.begin, 'day')) { + return true; + } + } + + return false; +} + +/** + * Modifier for DayPicker that checks if the previous day is booked. + * @param {Date} day + * @param {Object[]} reservations + * @returns {boolean} is previous day booked + */ +export function prevDayBookedModifier(day, reservations) { + if (day && reservations) { + const firstBooked = findPrevFirstClosestReservation(day, reservations); + if (firstBooked && moment(day).subtract(1, 'day').isSame(firstBooked.end, 'day')) { + return true; + } + } + + return false; +} + +/** + * Returns closed days modifier for DayPicker + * @param {Date} day + * @param {Object[]} openingHours + * @returns {boolean} is day closed + */ +export function closedDaysModifier(day, openingHours) { + const closedDays = getClosedDays(openingHours); + const momentDay = moment(day); + for (let index = 0; index < closedDays.length; index += 1) { + const closedDay = closedDays[index]; + if (momentDay.isSame(closedDay.date, 'day')) { + return true; + } + } + return false; +} + +/** + * Modifier for DayPicker that checks if the next day is closed. + * @param {Date} day + * @param {Object[]} openingHours + * @returns {boolean} is next day closed + */ +export function nextDayClosedModifier(day, openingHours) { + const closedDays = getClosedDays(openingHours); + const firstClosed = findFirstClosedDay(day, closedDays); + if (firstClosed && moment(day).add(1, 'day').isSame(firstClosed, 'day')) { + return true; + } + return false; +} + +/** + * Modifier for DayPicker that checks if the previous day is closed. + * @param {Date} day + * @param {Object[]} openingHours + * @returns {boolean} is previous day closed + */ +export function prevDayClosedModifier(day, openingHours) { + const closedDays = getClosedDays(openingHours); + const firstClosed = findPrevFirstClosedDay(day, closedDays); + if (firstClosed && moment(day).subtract(1, 'day').isSame(firstClosed, 'day')) { + return true; + } + return false; +} + +/** + * Finds first closed day after fromDate. + * @param {Date} fromDate + * @param {Object[]} closedDays opening hrs objects + * @returns {string|null} first closed day's date string + */ +export function findFirstClosedDay(fromDate, closedDays) { + const fromMoment = moment(fromDate); + const futureDates = closedDays.filter(closedDay => moment(closedDay.date).isAfter(fromMoment)); + const sortedDates = [...futureDates].sort( + (a, b) => moment(a).diff(fromMoment) - moment(b).diff(fromMoment)); + return sortedDates.length > 0 ? sortedDates[0].date : null; +} + +/** + * Finds first closed day before fromDate. + * @param {Date} fromDate + * @param {Object[]} closedDays + * @returns {string|null} first closed day's date string + */ +export function findPrevFirstClosedDay(fromDate, closedDays) { + const fromMoment = moment(fromDate); + const beforeDates = closedDays.filter(closedDay => moment(closedDay.date).isBefore(fromMoment)); + const sortedDates = [...beforeDates].sort( + (a, b) => moment(a).diff(fromMoment) - moment(b).diff(fromMoment)); + return sortedDates.length > 0 ? sortedDates[sortedDates.length - 1].date : null; +} + +/** + * Finds first reservation after fromDate. + * @param {Date} fromDate + * @param {Object[]} reservations + * @returns {Object|null} first reservation after fromDate or null if none found. + */ +export function findFirstClosestReservation(fromDate, reservations) { + const fromMoment = moment(fromDate); + const futureReservations = reservations.filter( + reservation => moment(reservation.begin).isAfter(fromMoment)); + const sortedReservations = [...futureReservations].sort( + (a, b) => moment(a.begin).diff(fromMoment) - moment(b.begin).diff(fromMoment)); + return sortedReservations.length > 0 ? sortedReservations[0] : null; +} + +/** + * Finds first reservation before fromDate. + * @param {Date} fromDate + * @param {Object[]} reservations + * @returns {Object|null} first reservation before fromDate or null if none found. + */ +export function findPrevFirstClosestReservation(fromDate, reservations) { + const fromMoment = moment(fromDate); + const futureReservations = reservations.filter( + reservation => moment(reservation.begin).isBefore(fromMoment)); + const sortedReservations = [...futureReservations].sort( + (a, b) => moment(a.begin).diff(fromMoment) - moment(b.begin).diff(fromMoment)); + return sortedReservations.length > 0 ? sortedReservations[sortedReservations.length - 1] : null; +} + +/** + * Finds first blocked day after fromDate. + * @param {Date} fromDate + * @param {Object[]} reservations + * @param {Object[]} openingHours + * @returns {string|null} first blocked day's date string or null if none found. + */ +export function getFirstBlockedDay(fromDate, reservations, openingHours) { + const firstClosedDay = findFirstClosedDay(fromDate, openingHours); + const firstReservation = findFirstClosestReservation(fromDate, reservations); + + // If both firstClosedDay and firstReservation are available, compare their dates + if (firstClosedDay && firstReservation) { + const firstClosedDayMoment = moment(firstClosedDay); + const firstReservationMoment = moment(firstReservation.begin); + + // Return whichever date is closer to fromDate + return firstClosedDayMoment.diff( + fromDate) < firstReservationMoment.diff(fromDate) ? firstClosedDay : firstReservation.begin; + } + + // Return whichever is available + if (firstClosedDay) { + return firstClosedDay; + } + if (firstReservation) { + return firstReservation.begin; + } + + return null; +} + +/** + * Sets date and time to a moment object. + * @param {Date} date + * @param {string} time + * @returns {Object} moment object + */ +export function setDatesTime(date, time) { + const timeUnits = getHoursMinutesSeconds(time); + const momentDate = moment(date); + momentDate.set({ + hour: timeUnits.hours, + minute: timeUnits.minutes, + second: timeUnits.seconds + }); + return momentDate; +} + +/** + * Combines date and time into a datetime string and returns it. + * @param {Date} date + * @param {string} time e.g. "12:00:00" + * @returns {string} datetime string e.g. "2018-02-01T12:00:00Z" + * or empty string if date or time is missing + */ +export function getOvernightDatetime(date, time) { + if (date && time) { + const momentDate = setDatesTime(date, time); + return momentDate.format('D.M.YYYY HH:mm'); + } + return ''; +} + +/** + * Gets hours, minutes and seconds from time string. + * @param {string} time + * @returns {Object} hours, minutes and seconds + */ +export function getHoursMinutesSeconds(time) { + if (time) { + const [hours, minutes, seconds] = time.split(':').map(Number); + return { hours, minutes, seconds }; + } + return { hours: 0, minutes: 0, seconds: 0 }; +} + +/** + * Handles formatting selected date and time for reservation redux actions. + * @param {Date} startDate + * @param {Date} endDate + * @param {string} startTime + * @param {string} endTime + * @param {string} resourceId + * @returns {Object} begin, end and resource + */ +export function handleFormattingSelected(startDate, endDate, startTime, endTime, resourceId) { + const startTimeUnits = getHoursMinutesSeconds(startTime); + const endTimeUnits = getHoursMinutesSeconds(endTime); + const begin = moment(startDate).set({ + hour: startTimeUnits.hours, + minute: startTimeUnits.minutes, + second: startTimeUnits.seconds + }).toISOString(); + const end = moment(endDate).set({ + hour: endTimeUnits.hours, + minute: endTimeUnits.minutes, + second: endTimeUnits.seconds + }).toISOString(); + return { begin, end, resource: resourceId }; +} + +/** + * Gets correct URL to reservation page when making a new reservation + * @param {Object} reservation + * @param {string} resourceId + * @returns {string} reservation URL + */ +export function getReservationUrl(reservation, resourceId) { + return `/reservation?id=${reservation ? reservation.id : ''}&resource=${resourceId}`; +} + +/** + * Returns true if reservation is allowed + * @param {Object} params + * @param {boolean} params.isLoggedIn + * @param {boolean} params.isStrongAuthSatisfied + * @param {boolean} params.isMaintenanceModeOn + * @param {Object} params.resource + * @param {boolean} params.hasAdminBypass + * @returns {boolean} true if reservation is allowed + */ +export function isReservingAllowed({ + isLoggedIn, isStrongAuthSatisfied, isMaintenanceModeOn, resource, hasAdminBypass +}) { + if (isMaintenanceModeOn || !resource) { + return false; + } + if (hasAdminBypass) { + return true; + } + const { authentication, reservable } = resource; + if (!reservable) { + return false; + } + const authRequired = authentication !== 'unauthenticated'; + + if (authRequired && (!isLoggedIn || !isStrongAuthSatisfied)) { + return false; + } + + return true; +} + +/** + * Returns correct general notification text e.g. user needs to login or maintenance mode is on + * @param {Object} params + * @param {boolean} params.isLoggedIn + * @param {boolean} params.isStrongAuthSatisfied + * @param {boolean} params.isMaintenanceModeOn + * @param {Object} params.resource + * @param {function} params.t + * @returns {string} notification text + */ +export function getNotificationText({ + isLoggedIn, isStrongAuthSatisfied, isMaintenanceModeOn, resource, t +}) { + if (isMaintenanceModeOn) { + return t('Notifications.cannotReserveDuringMaintenance'); + } + if (resource.reservable && !isStrongAuthSatisfied) { + return t('Notifications.loginToReserveStrongAuth'); + } + if (!isLoggedIn && resource.reservable) { + return t('Notifications.loginToReserve'); + } + return t('Notifications.noRightToReserve'); +} + +/** + * Returns correct not selectable text + * @param {boolean} isDateDisabled + * @param {boolean} booked + * @param {boolean} isNextBlocked + * @param {function} t + * @returns {string} not selectable text + */ +export function getNotSelectableNotificationText({ + isDateDisabled, booked, isNextBlocked, t +}) { + if (!isDateDisabled && !booked && isNextBlocked) { + return t('Notifications.overnight.notSelectableStart'); + } + return t('Notifications.overnight.notSelectable'); +} + +/** + * Removes the given reservation from reservations list + * @param {number} reservationId + * @param {Object[]} reservations + * @returns {Object[]} filtered reservations + */ +export function filterSelectedReservation(reservationId, reservations) { + return reservations.filter(reservation => reservation.id !== reservationId); +} + +/** + * Returns overnight selected dates' duration + * @param {Date} startDate + * @param {Date} endDate + * @param {string} overnightStartTime + * @param {string} overnightEndTime + * @returns {Object} moment duration + */ +export function getSelectedDuration(startDate, endDate, overnightStartTime, overnightEndTime) { + const start = setDatesTime(startDate, overnightStartTime); + const end = setDatesTime(endDate, overnightEndTime); + return moment.duration(end.diff(start)); +} + +/** + * Returns true if duration is below minPeriod + * @param {Object} duration moment + * @param {string} minPeriod + * @returns {boolean} true if duration is below minPeriod + */ +export function isDurationBelowMin(duration, minPeriod) { + return duration < moment.duration(minPeriod); +} + +/** + * Returns true if dates are same as initial dates + * @param {Date} startDate + * @param {Date} endDate + * @param {Date} initialStart + * @param {Date} initialEnd + * @returns {boolean} true if dates are same as initial dates + */ +export function areDatesSameAsInitialDates(startDate, endDate, initialStart, initialEnd) { + if (!startDate || !endDate || !initialStart || !initialEnd) { + return false; + } + return moment(startDate).isSame(initialStart) && moment(endDate).isSame(initialEnd); +} diff --git a/app/shared/overnight-calendar/tests/OvernightCalendar.spec.js b/app/shared/overnight-calendar/tests/OvernightCalendar.spec.js new file mode 100644 index 000000000..8df02987e --- /dev/null +++ b/app/shared/overnight-calendar/tests/OvernightCalendar.spec.js @@ -0,0 +1,143 @@ +import React from 'react'; +import MomentLocaleUtils from 'react-day-picker/moment'; +import moment from 'moment'; +import DayPicker from 'react-day-picker'; + +import { shallowWithIntl } from 'utils/testUtils'; +import Resource from '../../../utils/fixtures/Resource'; +import { UnconnectedOvernightCalendar as OvernightCalendar } from '../OvernightCalendar'; +import OvernightHiddenHeading from '../OvernightHiddenHeading'; +import OvernightLegends from '../OvernightLegends'; +import OvernightSummary from '../OvernightSummary'; +import OvernightEditSummary from '../OvernightEditSummary'; + + +describe('app/shared/overnight-calendar/OvernightCalendar', () => { + const resource = Resource.build({ + reservations: [], + overnightReservations: true, + overnightEndTime: '09:00:00', + overnightStartTime: '11:00:00', + }); + const defaultProps = { + currentLanguage: 'fi', + resource, + selected: [], + actions: { + setSelectedDatetimes: () => {}, + addNotification: () => {}, + }, + history: {}, + isStaff: false, + isSuperuser: false, + isLoggedIn: false, + isStrongAuthSatisfied: false, + isMaintenanceModeOn: false, + onEditCancel: () => {}, + onEditConfirm: () => {}, + handleDateChange: () => {}, + selectedDate: '2024-02-18', + params: { id: resource.id }, + }; + + function getWrapper(extraProps = {}) { + return shallowWithIntl(); + } + + describe('render', () => { + test('null, when resource is missing reservations', () => { + const wrapper = getWrapper({ resource: {} }); + expect(wrapper.html()).toBeNull(); + }); + + test('wrapping div', () => { + const wrapper = getWrapper(); + expect(wrapper.find('div.overnight-calendar')).toHaveLength(1); + }); + + test('OvernightHiddenHeading', () => { + const heading = getWrapper().find(OvernightHiddenHeading); + expect(heading).toHaveLength(1); + expect(heading.prop('date')).toEqual(moment(defaultProps.selectedDate).toDate()); + expect(heading.prop('locale')).toBe(defaultProps.currentLanguage); + expect(heading.prop('localeUtils')).toBe(MomentLocaleUtils); + }); + + test('DayPicker', () => { + const dayPicker = getWrapper().find(DayPicker); + expect(dayPicker).toHaveLength(1); + expect(dayPicker.prop('disabledDays')).toBeDefined(); + expect(dayPicker.prop('enableOutsideDays')).toBe(true); + expect(dayPicker.prop('firstDayOfWeek')).toBe(1); + expect(dayPicker.prop('initialMonth')).toEqual(moment(defaultProps.selectedDate).toDate()); + expect(dayPicker.prop('labels')).toStrictEqual({ previousMonth: 'Overnight.prevMonth', nextMonth: 'Overnight.nextMonth' }); + expect(dayPicker.prop('locale')).toBe(defaultProps.currentLanguage); + expect(dayPicker.prop('localeUtils')).toBe(MomentLocaleUtils); + expect(dayPicker.prop('modifiers')).toBeDefined(); + expect(dayPicker.prop('onDayClick')).toBeDefined(); + expect(dayPicker.prop('onMonthChange')).toBeDefined(); + expect(dayPicker.prop('selectedDays')).toStrictEqual([null, null]); + expect(dayPicker.prop('showOutsideDays')).toBe(true); + expect(dayPicker.prop('todayButton')).toBe('Overnight.currentMonth'); + }); + + test('OvernightLegends', () => { + const legends = getWrapper().find(OvernightLegends); + expect(legends).toHaveLength(1); + }); + + describe('when not editing', () => { + test('OvernightSummary', () => { + const summary = getWrapper().find(OvernightSummary); + expect(summary).toHaveLength(1); + expect(summary.prop('duration')).toBeDefined(); + expect(summary.prop('endDatetime')).toBe(''); + expect(summary.prop('handleSelectDatetimes')).toBeDefined(); + expect(summary.prop('isDurationBelowMin')).toBe(false); + expect(summary.prop('minDuration')).toBe(defaultProps.resource.minPeriod); + expect(summary.prop('selected')).toBe(defaultProps.selected); + expect(summary.prop('startDatetime')).toBe(''); + }); + + test('OvernightEditSummary is not rendered', () => { + const editSummary = getWrapper().find(OvernightEditSummary); + expect(editSummary).toHaveLength(0); + }); + }); + + describe('when editing', () => { + // editing when something is preselected + const selected = [ + { + begin: '2024-02-18T09:00:00', + end: '2024-02-19T11:00:00', + resource: resource.id, + }, + { + begin: '2024-02-18T09:00:00', + end: '2024-02-19T11:00:00', + resource: resource.id, + } + ]; + + test('OvernightSummary is not rendered', () => { + const summary = getWrapper({ selected }).find(OvernightSummary); + expect(summary).toHaveLength(0); + }); + + test('OvernightEditSummary', () => { + const editSummary = getWrapper({ selected }).find(OvernightEditSummary); + expect(editSummary).toHaveLength(1); + expect(editSummary.prop('datesSameAsInitial')).toBe(true); + expect(editSummary.prop('duration')).toBeDefined(); + expect(editSummary.prop('endDatetime')).toBe('19.2.2024 09:00'); + expect(editSummary.prop('isDurationBelowMin')).toBe(false); + expect(editSummary.prop('minDuration')).toBe(defaultProps.resource.minPeriod); + expect(editSummary.prop('onCancel')).toBe(defaultProps.onEditCancel); + expect(editSummary.prop('onConfirm')).toBeDefined(); + expect(editSummary.prop('selected')).toBe(selected); + expect(editSummary.prop('startDatetime')).toBe('18.2.2024 11:00'); + }); + }); + }); +}); diff --git a/app/shared/overnight-calendar/tests/OvernightCalendarSelector.spec.js b/app/shared/overnight-calendar/tests/OvernightCalendarSelector.spec.js new file mode 100644 index 000000000..86ac2be62 --- /dev/null +++ b/app/shared/overnight-calendar/tests/OvernightCalendarSelector.spec.js @@ -0,0 +1,45 @@ +import { getState } from 'utils/testUtils'; +import OvernightCalendarSelector from '../OvernightCalendarSelector'; + +describe('app/shared/overnight-calendar/OvernightCalendarSelector', () => { + function getProps(id = 'some-id') { + return { + location: { + search: 'date=2015-10-10', + }, + params: { + id, + }, + }; + } + + function getSelected(extraState) { + const state = getState(extraState); + const props = getProps(); + return OvernightCalendarSelector(state, props); + } + + test('returns selected', () => { + expect(getSelected().selected).toBeDefined(); + }); + + test('returns isMaintenanceModeOn', () => { + expect(getSelected().isMaintenanceModeOn).toBeDefined(); + }); + + test('returns isStrongAuthSatisfied', () => { + expect(getSelected().isStrongAuthSatisfied).toBeDefined(); + }); + + test('returns currentLanguage', () => { + expect(getSelected().currentLanguage).toBeDefined(); + }); + + test('returns isStaff', () => { + expect(getSelected().isStaff).toBeDefined(); + }); + + test('returns isSuperUserSelector', () => { + expect(getSelected().isSuperuser).toBeDefined(); + }); +}); diff --git a/app/shared/overnight-calendar/tests/OvernightEditSummary.spec.js b/app/shared/overnight-calendar/tests/OvernightEditSummary.spec.js new file mode 100644 index 000000000..4d97b8326 --- /dev/null +++ b/app/shared/overnight-calendar/tests/OvernightEditSummary.spec.js @@ -0,0 +1,109 @@ +import React from 'react'; +import moment from 'moment'; +import Button from 'react-bootstrap/lib/Button'; + +import { shallowWithIntl } from 'utils/testUtils'; +import OvernightEditSummary from '../OvernightEditSummary'; +import { getPrettifiedPeriodUnits } from '../../../utils/timeUtils'; + + +describe('app/shared/overnight-calendar/OvernightEditSummary', () => { + const startM = moment('2024-02-23 11:00:00'); + const endM = moment('2024-02-25 09:00:00'); + const defaultProps = { + duration: moment.duration(endM.diff(startM)), + selected: [], + endDatetime: '25.2.2024 09:00', + startDatetime: '23.2.2024 11:00', + isDurationBelowMin: false, + minDuration: '1 00:00:00', + datesSameAsInitial: true, + onCancel: () => {}, + onConfirm: () => {}, + }; + + function getWrapper(extraProps = {}) { + return shallowWithIntl(); + } + + describe('render', () => { + test('wrapping div', () => { + const wrapper = getWrapper().find('div.overnight-edit-summary'); + expect(wrapper).toHaveLength(1); + }); + + test('time range div', () => { + const div = getWrapper().find('div.overnight-edit-time-range'); + expect(div).toHaveLength(1); + }); + + test("time range div's status div", () => { + const div = getWrapper().find('.overnight-edit-time-range'); + const statusDiv = div.find('[role="status"]'); + expect(statusDiv).toHaveLength(1); + }); + + describe('when there is a valid time range', () => { + test('time range text', () => { + const div = getWrapper().find('.overnight-edit-time-range'); + const text = div.find('[role="status"]'); + const durText = getPrettifiedPeriodUnits(defaultProps.duration, 'common.unit.time.day.short'); + expect(text.text()).toEqual( + `TimeSlots.selectedDate 23.2.2024 11:00 - 25.2.2024 09:00 (${durText})`); + }); + }); + + describe('when there is an invalid time range', () => { + const startDatetime = ''; + const div = getWrapper({ startDatetime }).find('.overnight-edit-time-range'); + const statusDiv = div.find('[role="status"]'); + expect(statusDiv.text()).toEqual(''); + }); + + describe('when there is min duration error', () => { + test('error text', () => { + const paragraph = getWrapper({ datesSameAsInitial: false, isDurationBelowMin: true }).find('p.overnight-error'); + const minDurationText = getPrettifiedPeriodUnits(defaultProps.minDuration, 'common.unit.time.day.short'); + expect(paragraph).toHaveLength(1); + expect(paragraph.text()).toEqual(`Overnight.belowMinAlert (${minDurationText})`); + }); + }); + + describe('when there isnt min duration error', () => { + test('doesnt render error text', () => { + const paragraph = getWrapper({ datesSameAsInitial: false, isDurationBelowMin: false }).find('p.overnight-error'); + expect(paragraph).toHaveLength(0); + }); + }); + + test('reservation controls wrapping div', () => { + const div = getWrapper().find('div.app-ReservationTime__controls'); + expect(div).toHaveLength(1); + }); + + describe('reservation control buttons', () => { + const buttons = getWrapper().find(Button); + + test('correct amount of buttons', () => { + expect(buttons).toHaveLength(2); + }); + + test('first button', () => { + const firstButton = buttons.first(); + expect(firstButton.prop('bsStyle')).toBe('warning'); + expect(firstButton.prop('className')).toBe('cancel_Button'); + expect(firstButton.prop('onClick')).toBe(defaultProps.onCancel); + expect(firstButton.prop('children')).toBe('ReservationInformationForm.cancelEdit'); + }); + + test('second button', () => { + const secondButton = buttons.last(); + expect(secondButton.prop('bsStyle')).toBe('primary'); + expect(secondButton.prop('className')).toBe('next_Button'); + expect(secondButton.prop('disabled')).toBe(false); + expect(secondButton.prop('onClick')).toBe(defaultProps.onConfirm); + expect(secondButton.prop('children')).toBe('common.continue'); + }); + }); + }); +}); diff --git a/app/shared/overnight-calendar/tests/OvernightHiddenHeading.spec.js b/app/shared/overnight-calendar/tests/OvernightHiddenHeading.spec.js new file mode 100644 index 000000000..941357d0b --- /dev/null +++ b/app/shared/overnight-calendar/tests/OvernightHiddenHeading.spec.js @@ -0,0 +1,42 @@ +import React from 'react'; +import moment from 'moment'; +import MomentLocaleUtils from 'react-day-picker/moment'; + +import { shallowWithIntl } from 'utils/testUtils'; +import OvernightHiddenHeading from '../OvernightHiddenHeading'; + + +describe('app/shared/overnight-calendar/OvernightHiddenHeading', () => { + const defaultProps = { + date: moment('2024-02-23').toDate(), + localeUtils: MomentLocaleUtils, + locale: 'fi', + }; + + function getWrapper(extraProps = {}) { + return shallowWithIntl(); + } + + describe('render', () => { + test('wrapping div', () => { + const wrapper = getWrapper(); + expect(wrapper).toHaveLength(1); + expect(wrapper.prop('className')).toBe('sr-only'); + }); + + test('heading', () => { + const heading = getWrapper().find('h3'); + expect(heading).toHaveLength(1); + expect(heading.text()).toBe('Overnight.calendar'); + }); + + test('paragraph', () => { + const paragraph = getWrapper().find('p'); + const { localeUtils, locale, date } = defaultProps; + const expectedText = localeUtils.formatMonthTitle(date, locale); + expect(paragraph).toHaveLength(1); + expect(paragraph.prop('role')).toBe('status'); + expect(paragraph.text()).toBe(expectedText); + }); + }); +}); diff --git a/app/shared/overnight-calendar/tests/OvernightLegends.spec.js b/app/shared/overnight-calendar/tests/OvernightLegends.spec.js new file mode 100644 index 000000000..dcc485e1f --- /dev/null +++ b/app/shared/overnight-calendar/tests/OvernightLegends.spec.js @@ -0,0 +1,60 @@ +import React from 'react'; + +import { shallowWithIntl } from 'utils/testUtils'; +import OvernightLegends from '../OvernightLegends'; + + +describe('app/shared/overnight-calendar/OvernightLegends', () => { + function getWrapper(extraProps = {}) { + return shallowWithIntl(); + } + + describe('render', () => { + test('wrapping div', () => { + const wrapper = getWrapper(); + expect(wrapper).toHaveLength(1); + expect(wrapper.prop('className')).toBe('overnight-legends'); + expect(wrapper.prop('aria-hidden')).toBe(true); + }); + + test('rows', () => { + const rows = getWrapper().find('div.overnight-row'); + expect(rows).toHaveLength(2); + }); + + test('legends', () => { + const legends = getWrapper().find('div.overnight-legend'); + expect(legends).toHaveLength(4); + }); + + test('legend boxes', () => { + const legends = getWrapper().find('div.overnight-legend-box'); + expect(legends).toHaveLength(4); + const box1 = legends.at(0); + expect(box1.prop('className')).toBe('overnight-legend-box overnight-free'); + expect(box1.text()).toBe('21'); + const box2 = legends.at(1); + expect(box2.prop('className')).toBe('overnight-legend-box overnight-disabled'); + expect(box2.text()).toBe('21'); + const box3 = legends.at(2); + expect(box3.prop('className')).toBe('overnight-legend-box overnight-booked'); + expect(box3.text()).toBe('21'); + const box4 = legends.at(3); + expect(box4.prop('className')).toBe('overnight-legend-box overnight-selection'); + expect(box4.text()).toBe('21'); + }); + + test('legend texts', () => { + const legends = getWrapper().find('span.overnight-legend-text'); + expect(legends).toHaveLength(4); + const text1 = legends.at(0); + expect(text1.text()).toBe('Overnight.legend.available'); + const text2 = legends.at(1); + expect(text2.text()).toBe('Overnight.legend.notSelectable'); + const text3 = legends.at(2); + expect(text3.text()).toBe('Overnight.legend.reserved'); + const text4 = legends.at(3); + expect(text4.text()).toBe('Overnight.legend.ownSelection'); + }); + }); +}); diff --git a/app/shared/overnight-calendar/tests/OvernightSummary.spec.js b/app/shared/overnight-calendar/tests/OvernightSummary.spec.js new file mode 100644 index 000000000..bba7bb550 --- /dev/null +++ b/app/shared/overnight-calendar/tests/OvernightSummary.spec.js @@ -0,0 +1,95 @@ +import React from 'react'; +import moment from 'moment'; +import Button from 'react-bootstrap/lib/Button'; + +import { shallowWithIntl } from 'utils/testUtils'; +import { getPrettifiedPeriodUnits } from '../../../utils/timeUtils'; +import OvernightSummary from '../OvernightSummary'; + + +describe('app/shared/overnight-calendar/OvernightSummary', () => { + const startM = moment('2024-02-23 11:00:00'); + const endM = moment('2024-02-25 09:00:00'); + const defaultProps = { + duration: moment.duration(endM.diff(startM)), + selected: [], + endDatetime: '25.2.2024 09:00', + startDatetime: '23.2.2024 11:00', + isDurationBelowMin: false, + minDuration: '1 00:00:00', + handleSelectDatetimes: () => {}, + }; + + function getWrapper(extraProps = {}) { + return shallowWithIntl(); + } + + describe('render', () => { + describe('wrapping div', () => { + test('when there is valid time range', () => { + const wrapper = getWrapper().find('div.overnight-summary'); + expect(wrapper).toHaveLength(1); + }); + test('when there isnt valid time range', () => { + const startDatetime = ''; + const wrapper = getWrapper({ startDatetime }).find('div.overnight-summary'); + expect(wrapper).toHaveLength(1); + expect(wrapper.prop('className')).toBe('overnight-summary sr-only'); + }); + }); + + test('hidden h3', () => { + const heading = getWrapper().find('h3.visually-hidden'); + expect(heading).toHaveLength(1); + expect(heading.text()).toEqual('ReservationCalendar.Confirmation.header'); + expect(heading.prop('id')).toBe('timetable-summary'); + }); + + test('time range div', () => { + const div = getWrapper().find('div.summary-time-range'); + expect(div).toHaveLength(1); + expect(div.prop('role')).toBe('status'); + }); + + describe('when there is a valid time range', () => { + test('time range text', () => { + const div = getWrapper().find('.summary-time-range'); + const durText = getPrettifiedPeriodUnits(defaultProps.duration, 'common.unit.time.day.short'); + expect(div.text()).toEqual( + `TimeSlots.selectedDate 23.2.2024 11:00 - 25.2.2024 09:00 (${durText})`); + }); + }); + + describe('when there is an invalid time range', () => { + test('doesnt render time range', () => { + const startDatetime = ''; + const div = getWrapper({ startDatetime }).find('.summary-time-range'); + expect(div.text()).toEqual(''); + }); + }); + + describe('when there is min duration error', () => { + test('error text', () => { + const paragraph = getWrapper({ datesSameAsInitial: false, isDurationBelowMin: true }).find('p.overnight-error'); + const minDurationText = getPrettifiedPeriodUnits(defaultProps.minDuration, 'common.unit.time.day.short'); + expect(paragraph).toHaveLength(1); + expect(paragraph.text()).toEqual(`Overnight.belowMinAlert (${minDurationText})`); + }); + }); + + describe('when there isnt min duration error', () => { + test('doesnt render error text', () => { + const paragraph = getWrapper({ datesSameAsInitial: false, isDurationBelowMin: false }).find('p.overnight-error'); + expect(paragraph).toHaveLength(0); + }); + }); + + test('reserve button', () => { + const button = getWrapper().find(Button); + expect(button.prop('bsStyle')).toBe('primary'); + expect(button.prop('disabled')).toBe(false); + expect(button.prop('onClick')).toBe(defaultProps.handleSelectDatetimes); + expect(button.prop('children')).toBe('TimeSlots.reserveButton'); + }); + }); +}); diff --git a/app/shared/overnight-calendar/tests/overnightUtils.spec.js b/app/shared/overnight-calendar/tests/overnightUtils.spec.js new file mode 100644 index 000000000..ce1bbfe9b --- /dev/null +++ b/app/shared/overnight-calendar/tests/overnightUtils.spec.js @@ -0,0 +1,749 @@ +import moment from 'moment'; + +import { + areDatesSameAsInitialDates, + closedDaysModifier, + filterSelectedReservation, + findFirstClosedDay, + findFirstClosestReservation, + findPrevFirstClosedDay, + findPrevFirstClosestReservation, + getClosedDays, + getFirstBlockedDay, + getHoursMinutesSeconds, + getNotSelectableNotificationText, + getNotificationText, + getOvernightDatetime, + getReservationUrl, + getSelectedDuration, + handleDateSelect, + handleDisableDays, + handleFormattingSelected, + isDurationBelowMin, + isOverMaxPeriod, + isReservingAllowed, + nextDayBookedModifier, + nextDayClosedModifier, + prevDayBookedModifier, + prevDayClosedModifier, + reservationsModifier, + setDatesTime +} from '../overnightUtils'; +import Reservation from '../../../utils/fixtures/Reservation'; +import Resource from '../../../utils/fixtures/Resource'; + +describe('app/shared/overnight-calendar/overnightUtils', () => { + describe('handleDateSelect', () => { + test('returns undefined when value if falsy', () => { + expect(handleDateSelect({ value: null })).toBeUndefined(); + }); + + test('sets startDate when value is not set', () => { + const setStartDate = jest.fn(); + const val = new Date(); + const overnightStartTime = '11:00:00'; + const expectedCall = setDatesTime(val, overnightStartTime).toDate(); + handleDateSelect({ + value: val, startDate: null, setStartDate, overnightStartTime + }); + expect(setStartDate).toHaveBeenCalled(); + expect(setStartDate).toHaveBeenCalledWith(expectedCall); + }); + + test('sets startDate and endDate to null if start is selected and same as value', () => { + const setStartDate = jest.fn(); + const setEndDate = jest.fn(); + const overnightStartTime = '11:00:00'; + const date = setDatesTime(new Date(), overnightStartTime).toDate(); + handleDateSelect({ + value: date, startDate: date, setStartDate, setEndDate, overnightStartTime + }); + expect(setStartDate).toHaveBeenCalled(); + expect(setStartDate).toHaveBeenCalledWith(null); + expect(setEndDate).toHaveBeenCalled(); + expect(setEndDate).toHaveBeenCalledWith(null); + }); + + test('sets endDate if startDate already set, value is not same as start and endDate not set', () => { + const setStartDate = jest.fn(); + const setEndDate = jest.fn(); + const overnightStartTime = '11:00:00'; + const overnightEndTime = '13:00:00'; + const val = new Date(); + const start = new Date('2024-02-21'); + const expectedCall = setDatesTime(val, overnightEndTime).toDate(); + handleDateSelect({ + value: val, + startDate: start, + setStartDate, + setEndDate, + overnightStartTime, + overnightEndTime + }); + expect(setEndDate).toHaveBeenCalled(); + expect(setEndDate).toHaveBeenCalledWith(expectedCall); + }); + + test('sets startDate and endDate to null if start and end are selected and end is same as value', () => { + const setStartDate = jest.fn(); + const setEndDate = jest.fn(); + const overnightStartTime = '11:00:00'; + const overnightEndTime = '13:00:00'; + const val = new Date(); + const start = new Date('2024-02-21'); + const end = setDatesTime(val, overnightEndTime).toDate(); + handleDateSelect({ + value: val, + startDate: start, + endDate: end, + setStartDate, + setEndDate, + overnightStartTime, + overnightEndTime + }); + expect(setStartDate).toHaveBeenCalled(); + expect(setStartDate).toHaveBeenCalledWith(null); + expect(setEndDate).toHaveBeenCalled(); + expect(setEndDate).toHaveBeenCalledWith(null); + }); + }); + + describe('handleDisableDays', () => { + const now = moment('2024-04-20'); + const day = now.toDate(); + const reservable = true; + const reservableAfter = '2024-04-19T00:00:00+03:00'; + const reservableBefore = '2024-04-29T00:00:00+03:00'; + const startDate = null; + const openingHours = [ + { date: '2024-04-19', closes: null, opens: null }, + { date: '2024-04-20', closes: '2024-04-20T20:00:00+03:00', opens: '2024-04-20T06:00:00+03:00' }, + { date: '2024-04-21', closes: '2024-04-21T20:00:00+03:00', opens: '2024-04-21T06:00:00+03:00' }, + { date: '2024-04-22', closes: '2024-04-22T20:00:00+03:00', opens: '2024-04-22T06:00:00+03:00' }, + { date: '2024-04-23', closes: '2024-04-23T20:00:00+03:00', opens: '2024-04-23T06:00:00+03:00' }, + { date: '2024-04-24', closes: '2024-04-24T20:00:00+03:00', opens: '2024-04-24T06:00:00+03:00' }, + { date: '2024-04-25', closes: '2024-04-25T20:00:00+03:00', opens: '2024-04-25T06:00:00+03:00' }, + { date: '2024-04-26', closes: '2024-04-26T20:00:00+03:00', opens: '2024-04-26T06:00:00+03:00' }, + { date: '2024-04-27', closes: '2024-04-27T20:00:00+03:00', opens: '2024-04-27T06:00:00+03:00' }, + { date: '2024-04-28', closes: '2024-04-28T20:00:00+03:00', opens: '2024-04-28T06:00:00+03:00' }, + { date: '2024-04-29', closes: '2024-04-29T20:00:00+03:00', opens: '2024-04-29T06:00:00+03:00' }, + { date: '2024-04-30', closes: null, opens: null }, + ]; + const reservations = [ + Reservation.build({ begin: '2024-04-23T13:00:00+03:00', end: '2024-04-24T09:00:00+03:00' }) + ]; + const maxPeriod = '3 00:00:00'; + const overnightEndTime = '09:00:00'; + const overnightStartTime = '13:00:00'; + const hasAdminBypass = false; + const params = { + now, + day, + reservable, + reservableAfter, + reservableBefore, + startDate, + openingHours, + reservations, + maxPeriod, + overnightEndTime, + overnightStartTime, + hasAdminBypass + }; + + test('returns false with correct params', () => { + expect(handleDisableDays(params)).toBe(false); + }); + + describe('returns true when', () => { + test('no admin bypass and not reservable', () => { + expect(handleDisableDays( + { ...params, hasAdminBypass: false, reservable: false } + )).toBe(true); + }); + + test('now is after given day', () => { + expect(handleDisableDays( + { + ...params, + now: moment('2024-04-21') + } + )).toBe(true); + }); + + test('day is before reservable after limit', () => { + expect(handleDisableDays( + { + ...params, + reservableAfter: '2024-04-21T01:00:00+03:00' + } + )).toBe(true); + }); + + test('day is after reservable before limit', () => { + expect(handleDisableDays( + { + ...params, + day: moment('2024-04-29').toDate(), + reservableBefore: '2024-04-28T01:00:00+03:00' + } + )).toBe(true); + }); + + test('day is before selected start date', () => { + expect(handleDisableDays( + { + ...params, + day: moment('2024-04-24').toDate(), + startDate: moment('2024-04-26').toDate(), + } + )).toBe(true); + }); + + test('day has reservation', () => { + expect(handleDisableDays( + { + ...params, + day: moment('2024-04-23').toDate(), + } + )).toBe(true); + }); + + test('day is closed', () => { + expect(handleDisableDays( + { + ...params, + day: moment('2024-04-19').toDate(), + } + )).toBe(true); + }); + + test('day is over max reservation time', () => { + expect(handleDisableDays( + { + ...params, + day: moment('2024-04-29').toDate(), + startDate: moment('2024-04-25').toDate(), + } + )).toBe(true); + }); + + test('day is after a reservation or closed day when startDate is selected', () => { + expect(handleDisableDays( + { + ...params, + maxPeriod: '10 00:00:00', + day: moment('2024-04-25').toDate(), + startDate: moment('2024-04-22').toDate(), + } + )).toBe(true); + }); + }); + }); + + describe('isOverMaxPeriod', () => { + const overnightEndTime = '09:00:00'; + const overnightStartTime = '13:00:00'; + const startDate = moment('2024-04-20').toDate(); + const endDate = moment('2024-04-22').toDate(); + + test('returns false when not over max period', () => { + expect(isOverMaxPeriod( + startDate, endDate, '10 00:00:00', overnightEndTime, overnightStartTime + )).toBe(false); + }); + + test('returns true when over max period', () => { + expect(isOverMaxPeriod( + startDate, endDate, '1 10:00:00', overnightEndTime, overnightStartTime + )).toBe(true); + }); + }); + + describe('getClosedDays', () => { + test('returns closed days', () => { + const openingHours = [ + { date: '2024-04-19', closes: null, opens: null }, + { date: '2024-04-20', closes: '2024-04-20T20:00:00+03:00', opens: '2024-04-20T06:00:00+03:00' }, + { date: '2024-04-21', closes: '2024-04-21T20:00:00+03:00', opens: '2024-04-21T06:00:00+03:00' }, + { date: '2024-04-22', closes: '2024-04-22T20:00:00+03:00', opens: '2024-04-22T06:00:00+03:00' }, + { date: '2024-04-23', closes: '2024-04-23T20:00:00+03:00', opens: '2024-04-23T06:00:00+03:00' }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + expect(getClosedDays(openingHours)).toStrictEqual([openingHours[0], openingHours[5]]); + }); + }); + + describe('reservationsModifier', () => { + const reservations = [ + Reservation.build({ begin: '2024-04-23T13:00:00+03:00', end: '2024-04-24T09:00:00+03:00' }) + ]; + test('returns true when day has reservation', () => { + expect(reservationsModifier( + moment('2024-04-23').toDate(), reservations)).toBe(true); + expect(reservationsModifier( + moment('2024-04-24').toDate(), reservations)).toBe(true); + }); + + test('returns false when day has no reservation', () => { + expect(reservationsModifier( + moment('2024-04-22').toDate(), reservations)).toBe(false); + expect(reservationsModifier( + moment('2024-04-25').toDate(), reservations)).toBe(false); + }); + }); + + describe('nextDayBookedModifier', () => { + const reservations = [ + Reservation.build({ begin: '2024-04-23T13:00:00+03:00', end: '2024-04-24T09:00:00+03:00' }) + ]; + test('returns true when next day has reservation', () => { + expect(nextDayBookedModifier( + moment('2024-04-22').toDate(), reservations)).toBe(true); + }); + + test('returns false when next day has no reservation', () => { + expect(nextDayBookedModifier( + moment('2024-04-20').toDate(), reservations)).toBe(false); + expect(nextDayBookedModifier( + moment('2024-04-24').toDate(), reservations)).toBe(false); + }); + }); + + describe('prevDayBookedModifier', () => { + const reservations = [ + Reservation.build({ begin: '2024-04-23T13:00:00+03:00', end: '2024-04-24T09:00:00+03:00' }) + ]; + test('returns true when previous day has reservation', () => { + expect(prevDayBookedModifier( + moment('2024-04-25').toDate(), reservations)).toBe(true); + }); + + test('returns false when previous day has no reservation', () => { + expect(prevDayBookedModifier( + moment('2024-04-21').toDate(), reservations)).toBe(false); + expect(prevDayBookedModifier( + moment('2024-04-23').toDate(), reservations)).toBe(false); + }); + }); + + describe('closedDaysModifier', () => { + const openingHours = [ + { date: '2024-04-19', closes: null, opens: null }, + { date: '2024-04-20', closes: '2024-04-20T20:00:00+03:00', opens: '2024-04-20T06:00:00+03:00' }, + { date: '2024-04-21', closes: null, opens: null }, + { date: '2024-04-22', closes: '2024-04-22T20:00:00+03:00', opens: '2024-04-22T06:00:00+03:00' }, + { date: '2024-04-23', closes: '2024-04-23T20:00:00+03:00', opens: '2024-04-23T06:00:00+03:00' }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + test('returns true when day is closed', () => { + expect(closedDaysModifier( + moment('2024-04-19').toDate(), openingHours)).toBe(true); + expect(closedDaysModifier( + moment('2024-04-21').toDate(), openingHours)).toBe(true); + expect(closedDaysModifier( + moment('2024-04-24').toDate(), openingHours)).toBe(true); + }); + + test('returns false when day is not closed', () => { + expect(closedDaysModifier( + moment('2024-04-20').toDate(), openingHours)).toBe(false); + expect(closedDaysModifier( + moment('2024-04-22').toDate(), openingHours)).toBe(false); + expect(closedDaysModifier( + moment('2024-04-23').toDate(), openingHours)).toBe(false); + expect(closedDaysModifier( + moment('2024-04-25').toDate(), openingHours)).toBe(false); + }); + }); + + describe('nextDayClosedModifier', () => { + const openingHours = [ + { date: '2024-04-19', closes: null, opens: null }, + { date: '2024-04-20', closes: '2024-04-20T20:00:00+03:00', opens: '2024-04-20T06:00:00+03:00' }, + { date: '2024-04-21', closes: '2024-04-21T20:00:00+03:00', opens: '2024-04-21T06:00:00+03:00' }, + { date: '2024-04-22', closes: '2024-04-22T20:00:00+03:00', opens: '2024-04-22T06:00:00+03:00' }, + { date: '2024-04-23', closes: '2024-04-23T20:00:00+03:00', opens: '2024-04-23T06:00:00+03:00' }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + test('returns true when next day is closed', () => { + expect(nextDayClosedModifier( + moment('2024-04-23').toDate(), openingHours)).toBe(true); + }); + + test('returns false when next day is not closed', () => { + expect(nextDayClosedModifier( + moment('2024-04-20').toDate(), openingHours)).toBe(false); + expect(nextDayClosedModifier( + moment('2024-04-24').toDate(), openingHours)).toBe(false); + }); + }); + + describe('prevDayClosedModifier', () => { + const openingHours = [ + { date: '2024-04-19', closes: null, opens: null }, + { date: '2024-04-20', closes: '2024-04-20T20:00:00+03:00', opens: '2024-04-20T06:00:00+03:00' }, + { date: '2024-04-21', closes: '2024-04-21T20:00:00+03:00', opens: '2024-04-21T06:00:00+03:00' }, + { date: '2024-04-22', closes: '2024-04-22T20:00:00+03:00', opens: '2024-04-22T06:00:00+03:00' }, + { date: '2024-04-23', closes: '2024-04-23T20:00:00+03:00', opens: '2024-04-23T06:00:00+03:00' }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + test('returns true when previous day is closed', () => { + expect(prevDayClosedModifier( + moment('2024-04-20').toDate(), openingHours)).toBe(true); + }); + + test('returns false when previous day is not closed', () => { + expect(prevDayClosedModifier( + moment('2024-04-21').toDate(), openingHours)).toBe(false); + expect(prevDayClosedModifier( + moment('2024-04-24').toDate(), openingHours)).toBe(false); + }); + }); + + describe('findFirstClosedDay', () => { + test('returns first closed day', () => { + const openingHours = [ + { date: '2024-04-22', closes: null, opens: null }, + { date: '2024-04-23', closes: null, opens: null }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + expect(findFirstClosedDay(moment('2024-04-23').toDate(), openingHours)) + .toBe(openingHours[2].date); + }); + test('returns null if no closed day', () => { + const openingHours = [ + { date: '2024-04-22', closes: null, opens: null }, + { date: '2024-04-23', closes: null, opens: null }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + expect(findFirstClosedDay(moment('2024-04-25').toDate(), openingHours)) + .toBe(null); + }); + }); + + describe('findPrevFirstClosedDay', () => { + const openingHours = [ + { date: '2024-04-22', closes: null, opens: null }, + { date: '2024-04-23', closes: null, opens: null }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + test('returns first closed day', () => { + expect(findPrevFirstClosedDay(moment('2024-04-23').toDate(), openingHours)) + .toBe(openingHours[0].date); + expect(findPrevFirstClosedDay(moment('2024-04-24').toDate(), openingHours)) + .toBe(openingHours[1].date); + }); + test('returns null if no closed day', () => { + expect(findPrevFirstClosedDay(moment('2024-04-22').toDate(), openingHours)) + .toBe(null); + }); + }); + + describe('findFirstClosestReservation', () => { + const reservations = [ + Reservation.build({ + begin: '2024-04-23T13:00:00+03:00', + end: '2024-04-24T09:00:00+03:00' + }), + Reservation.build({ + begin: '2024-04-25T13:00:00+03:00', + end: '2024-04-27T09:00:00+03:00' + }) + ]; + test('returns closest reservation', () => { + expect(findFirstClosestReservation(moment('2024-04-20').toDate(), reservations)) + .toBe(reservations[0]); + expect(findFirstClosestReservation(moment('2024-04-24').toDate(), reservations)) + .toBe(reservations[1]); + }); + test('returns null if no closed day', () => { + expect(findFirstClosestReservation(moment('2024-04-27').toDate(), reservations)) + .toBe(null); + }); + }); + + describe('findPrevFirstClosestReservation', () => { + const reservations = [ + Reservation.build({ + begin: '2024-04-23T13:00:00+03:00', + end: '2024-04-24T09:00:00+03:00' + }), + Reservation.build({ + begin: '2024-04-25T13:00:00+03:00', + end: '2024-04-27T09:00:00+03:00' + }) + ]; + test('returns closest reservation', () => { + expect(findPrevFirstClosestReservation(moment('2024-04-25').toDate(), reservations)) + .toBe(reservations[0]); + expect(findPrevFirstClosestReservation(moment('2024-04-29').toDate(), reservations)) + .toBe(reservations[1]); + }); + test('returns null if no closed day', () => { + expect(findPrevFirstClosestReservation(moment('2024-04-22').toDate(), reservations)) + .toBe(null); + }); + }); + + describe('getFirstBlockedDay', () => { + const openingHours = [ + { date: '2024-04-22', closes: null, opens: null }, + { date: '2024-04-23', closes: null, opens: null }, + { date: '2024-04-24', closes: null, opens: null }, + ]; + + const reservations = [ + Reservation.build({ + begin: '2024-04-23T13:00:00+03:00', + end: '2024-04-24T09:00:00+03:00' + }), + Reservation.build({ + begin: '2024-04-25T13:00:00+03:00', + end: '2024-04-27T09:00:00+03:00' + }) + ]; + + test('returns first blocked day', () => { + expect(getFirstBlockedDay(moment('2024-04-20').toDate(), reservations, openingHours)) + .toBe(openingHours[0].date); + expect(getFirstBlockedDay(moment('2024-04-24').toDate(), reservations, openingHours)) + .toBe(reservations[1].begin); + expect(getFirstBlockedDay(moment('2024-04-20').toDate(), reservations, [])) + .toBe(reservations[0].begin); + expect(getFirstBlockedDay(moment('2024-04-23').toDate(), [], openingHours)) + .toBe(openingHours[2].date); + }); + + test('returns null if no blocking day found', () => { + expect(getFirstBlockedDay(moment('2024-04-28').toDate(), reservations, openingHours)) + .toBe(null); + }); + }); + + describe('setDatesTime', () => { + test('sets time correctly to moment obj', () => { + const date = moment('2024-04-23').toDate(); + const time = '10:30:00'; + const result = setDatesTime(date, time); + expect(result).toEqual(moment(date).set({ + hour: 10, minute: 30, second: 0, millisecond: 0 + })); + }); + }); + + describe('getOvernightDatetime', () => { + test('returns correct datetime string', () => { + const date = moment('2024-04-23').toDate(); + const time = '10:30:00'; + const result = getOvernightDatetime(date, time); + expect(result).toBe('23.4.2024 10:30'); + }); + test('returns empty string when date or time is missing', () => { + expect(getOvernightDatetime(null, null)).toBe(''); + expect(getOvernightDatetime(null, '10:30:00')).toBe(''); + expect(getOvernightDatetime(moment('2024-04-23').toDate(), null)).toBe(''); + }); + }); + + describe('getHoursMinutesSeconds', () => { + test('returns correct obj', () => { + expect(getHoursMinutesSeconds('10:30:00')).toStrictEqual( + { hours: 10, minutes: 30, seconds: 0 }); + expect(getHoursMinutesSeconds('00:30:00')).toStrictEqual( + { hours: 0, minutes: 30, seconds: 0 }); + expect(getHoursMinutesSeconds(undefined)).toStrictEqual( + { hours: 0, minutes: 0, seconds: 0 }); + }); + }); + + describe('handleFormattingSelected', () => { + test('returns correct obj', () => { + const startDate = moment('2024-04-23').toDate(); + const endDate = moment('2024-04-26').toDate(); + const startTime = '10:30:00'; + const endTime = '12:30:00'; + const resourceId = 'abc123'; + expect(handleFormattingSelected( + startDate, endDate, startTime, endTime, resourceId)) + .toStrictEqual({ + begin: '2024-04-23T07:30:00.000Z', + end: '2024-04-26T09:30:00.000Z', + resource: resourceId + }); + }); + }); + + describe('getReservationUrl', () => { + test('returns correct url string', () => { + const reservation = Reservation.build({ id: 'res123' }); + expect(getReservationUrl(reservation, 'abc123')) + .toBe('/reservation?id=res123&resource=abc123'); + }); + }); + + describe('isReservingAllowed', () => { + const params = { + isLoggedIn: true, + isStrongAuthSatisfied: true, + isMaintenanceModeOn: false, + resource: Resource.build(), + hasAdminBypass: false + }; + describe('returns false when', () => { + test('maintenance mode is on', () => { + expect(isReservingAllowed({ ...params, isMaintenanceModeOn: true })) + .toBe(false); + }); + test('resource is missing', () => { + expect(isReservingAllowed({ ...params, resource: undefined })) + .toBe(false); + }); + test('resource is not reservable', () => { + expect(isReservingAllowed( + { ...params, resource: Resource.build({ reservable: false }) })) + .toBe(false); + }); + test('auth required and not logged in', () => { + expect(isReservingAllowed( + { ...params, isLoggedIn: false })) + .toBe(false); + expect(isReservingAllowed( + { ...params, isStrongAuthSatisfied: false })) + .toBe(false); + }); + }); + describe('returns true when', () => { + test('has admin bypass', () => { + expect(isReservingAllowed({ ...params, hasAdminBypass: true })) + .toBe(true); + }); + test('auth not required and not logged in', () => { + expect(isReservingAllowed( + { + ...params, + resource: Resource.build({ authentication: 'unauthenticated' }), + isLoggedIn: false, + isStrongAuthSatisfied: false, + })) + .toBe(true); + }); + }); + }); + + describe('getNotificationText', () => { + const params = { + isLoggedIn: true, + isStrongAuthSatisfied: true, + isMaintenanceModeOn: false, + resource: Resource.build(), + t: str => str, + }; + describe('returns correct text', () => { + test('when maintenance mode is on', () => { + expect(getNotificationText({ ...params, isMaintenanceModeOn: true })) + .toBe('Notifications.cannotReserveDuringMaintenance'); + }); + test('when strong auth required', () => { + expect(getNotificationText({ ...params, isStrongAuthSatisfied: false })) + .toBe('Notifications.loginToReserveStrongAuth'); + }); + test('when login required', () => { + expect(getNotificationText({ ...params, isLoggedIn: false })) + .toBe('Notifications.loginToReserve'); + }); + test('default text when login ok and no maintenance mode', () => { + expect(getNotificationText({ ...params })) + .toBe('Notifications.noRightToReserve'); + }); + }); + }); + + describe('getNotSelectableNotificationText', () => { + const params = { + isDateDisabled: false, + booked: false, + isNextBlocked: false, + t: str => str, + }; + describe('returns correct text', () => { + test('when date not disabled, not booked and next is blocked', () => { + expect(getNotSelectableNotificationText({ ...params, isNextBlocked: true })) + .toBe('Notifications.overnight.notSelectableStart'); + }); + test('default text', () => { + expect(getNotSelectableNotificationText({ ...params })) + .toBe('Notifications.overnight.notSelectable'); + }); + }); + }); + + describe('filterSelectedReservation', () => { + test('returns correct reservations', () => { + const reservations = [ + Reservation.build({ id: 'res1' }), + Reservation.build({ id: 'res2' }), + Reservation.build({ id: 'res3' }), + ]; + expect(filterSelectedReservation('res2', reservations)) + .toStrictEqual([reservations[0], reservations[2]]); + expect(filterSelectedReservation('res5', reservations)) + .toStrictEqual([reservations[0], reservations[1], reservations[2]]); + }); + }); + + describe('getSelectedDuration', () => { + test('returns correct duration', () => { + const startDate = moment('2024-04-23').toDate(); + const endDate = moment('2024-04-26').toDate(); + const overnightStartTime = '10:30:00'; + const overnightEndTime = '12:30:00'; + const expected = moment.duration( + setDatesTime(endDate, overnightEndTime).diff(setDatesTime(startDate, overnightStartTime))); + expect(getSelectedDuration(startDate, endDate, overnightStartTime, overnightEndTime)) + .toStrictEqual(expected); + }); + }); + + describe('isDurationBelowMin', () => { + test('returns correct result', () => { + expect(isDurationBelowMin(moment.duration(2, 'days'), '10:00:00')).toBe(false); + expect(isDurationBelowMin(moment.duration(2, 'days'), '3 10:00:00')).toBe(true); + expect(isDurationBelowMin(moment.duration(3, 'days'), '3 10:00:00')).toBe(true); + expect(isDurationBelowMin(moment.duration(4, 'days'), '3 10:00:00')).toBe(false); + }); + }); + + describe('areDatesSameAsInitialDates', () => { + const startDate = moment('2024-04-23').toDate(); + const endDate = moment('2024-04-26').toDate(); + const initialStart = moment('2024-04-23').toDate(); + const initialEnd = moment('2024-04-26').toDate(); + test('returns false when any of the params is missing', () => { + expect(areDatesSameAsInitialDates(null, null, null, undefined)) + .toBe(false); + expect(areDatesSameAsInitialDates(startDate, null, null, undefined)) + .toBe(false); + expect(areDatesSameAsInitialDates(null, endDate, null, undefined)) + .toBe(false); + expect(areDatesSameAsInitialDates(null, null, initialStart, undefined)) + .toBe(false); + expect(areDatesSameAsInitialDates(startDate, endDate, null, initialEnd)) + .toBe(false); + }); + + test('returns true when all params are the same', () => { + expect(areDatesSameAsInitialDates(startDate, endDate, initialStart, initialEnd)) + .toBe(true); + }); + + test('returns false when not all params are same', () => { + expect(areDatesSameAsInitialDates( + startDate, endDate, moment('2024-04-22').toDate(), initialEnd)) + .toBe(false); + expect(areDatesSameAsInitialDates( + startDate, endDate, initialStart, moment('2024-04-25').toDate())) + .toBe(false); + }); + }); +}); diff --git a/app/shared/reservation-date/ReservationDate.js b/app/shared/reservation-date/ReservationDate.js index f2bf8e74c..e4929e87d 100644 --- a/app/shared/reservation-date/ReservationDate.js +++ b/app/shared/reservation-date/ReservationDate.js @@ -4,8 +4,9 @@ import moment from 'moment'; import iconClock from 'assets/icons/clock-o.svg'; import { getPrettifiedDuration } from 'utils/timeUtils'; +import injectT from '../../i18n/injectT'; -function ReservationDate({ beginDate, endDate }) { +function ReservationDate({ beginDate, endDate, t }) { if (!beginDate || !endDate) { return ; } @@ -16,7 +17,7 @@ function ReservationDate({ beginDate, endDate }) { const month = reservationBegin.format('MMMM'); const beginTime = reservationBegin.format('HH:mm'); const endTime = reservationEnd.format('HH:mm'); - const duration = getPrettifiedDuration(reservationBegin, reservationEnd); + const duration = getPrettifiedDuration(reservationBegin, reservationEnd, t('common.unit.time.day.short')); return (
    @@ -36,6 +37,7 @@ function ReservationDate({ beginDate, endDate }) { ReservationDate.propTypes = { beginDate: PropTypes.string, endDate: PropTypes.string, + t: PropTypes.func.isRequired, }; -export default ReservationDate; +export default injectT(ReservationDate); diff --git a/app/shared/reservation-date/ReservationOvernightDate.js b/app/shared/reservation-date/ReservationOvernightDate.js new file mode 100644 index 000000000..560bea5a7 --- /dev/null +++ b/app/shared/reservation-date/ReservationOvernightDate.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import moment from 'moment'; + +import iconClock from 'assets/icons/clock-o.svg'; +import iconCalendar from 'assets/icons/calendar.svg'; +import { getPrettifiedDuration } from 'utils/timeUtils'; +import injectT from '../../i18n/injectT'; + +function ReservationOvernightDate({ beginDate, endDate, t }) { + if (!beginDate || !endDate) { + return ; + } + + const reservationBegin = moment(beginDate); + const reservationEnd = moment(endDate); + const begin = reservationBegin.format('D.M.YYYY HH:mm'); + const end = reservationEnd.format('D.M.YYYY HH:mm'); + const duration = getPrettifiedDuration(reservationBegin, reservationEnd, t('common.unit.time.day.short')); + + return ( +
    +

    + + {`${t('common.time.begin')}: `} + {begin} +

    +

    + + {`${t('common.time.end')}: `} + {end} +

    +

    + + {`${t('common.time.duration')}: `} + {duration} +

    +
    + ); +} + +ReservationOvernightDate.propTypes = { + beginDate: PropTypes.string, + endDate: PropTypes.string, + t: PropTypes.func, +}; + +export default injectT(ReservationOvernightDate); diff --git a/app/shared/reservation-date/ReservationOvernightDate.spec.js b/app/shared/reservation-date/ReservationOvernightDate.spec.js new file mode 100644 index 000000000..e80ae1203 --- /dev/null +++ b/app/shared/reservation-date/ReservationOvernightDate.spec.js @@ -0,0 +1,51 @@ +import React from 'react'; + +import { shallowWithIntl } from 'utils/testUtils'; +import ReservationOvernightDate from './ReservationOvernightDate'; + +describe('shared/reservation-date/ReservationOvernightDate', () => { + function getWrapper(extraProps) { + return shallowWithIntl(); + } + + let wrapper; + + const beginDate = '2018-01-29T13:00:00+02:00'; + const endDate = '2018-01-31T09:00:00+02:00'; + + beforeAll(() => { + wrapper = getWrapper({ + beginDate, + endDate + }); + }); + + test('renders a container div', () => { + const container = wrapper.find('div.reservation-date'); + expect(container.length).toBe(1); + }); + + test('renders time texts', () => { + const texts = wrapper.find('p.reservation-date__time'); + expect(texts.length).toBe(3); + expect(texts.at(0).text()).toBe('common.time.begin: 29.1.2018 13:00'); + expect(texts.at(1).text()).toBe('common.time.end: 31.1.2018 09:00'); + expect(texts.at(2).text()).toBe('common.time.duration: 1common.unit.time.day.short 20h'); + }); + + test('renders time icons', () => { + const icons = wrapper.find('img.reservation-date__icon'); + expect(icons.length).toBe(3); + expect(icons.at(0).prop('src')).toBe('test-file-stub'); + expect(icons.at(0).prop('alt')).toBe(''); + expect(icons.at(1).prop('src')).toBe('test-file-stub'); + expect(icons.at(1).prop('alt')).toBe(''); + expect(icons.at(2).prop('src')).toBe('test-file-stub'); + expect(icons.at(2).prop('alt')).toBe(''); + }); + + test('renders empty when start or end date missing', () => { + const emptyWrapper = getWrapper({}); + expect(emptyWrapper.equals()).toBe(true); + }); +}); diff --git a/app/shared/reservation-date/_reservation-date.scss b/app/shared/reservation-date/_reservation-date.scss index 377e72ee6..a9dd188c2 100644 --- a/app/shared/reservation-date/_reservation-date.scss +++ b/app/shared/reservation-date/_reservation-date.scss @@ -10,7 +10,7 @@ display: block; font-weight: bold; } - + &__month { font-size: initial; background-color: $blue; @@ -20,7 +20,7 @@ display: block; } - h1{ + h1 { margin-top: 4px; } @@ -33,21 +33,28 @@ width: 150px; } - &__day-of-week{ + &__day-of-week { display: block; text-align: center; width: 150px; } + &__icon { height: 20px; margin-right: 6px; } + &__time { margin-bottom: 0; font-weight: bold; text-align: center; + .glyphicon { vertical-align: top; } } -} + + &__overnight-time { + font-weight: normal; + } +} \ No newline at end of file diff --git a/app/state/reducers/ui/reservationsReducer.js b/app/state/reducers/ui/reservationsReducer.js index 32fe5a8b0..8e51e401e 100644 --- a/app/state/reducers/ui/reservationsReducer.js +++ b/app/state/reducers/ui/reservationsReducer.js @@ -180,6 +180,10 @@ function reservationsReducer(state = initialState, action) { }); } + case types.UI.SET_SELECTED_DATETIMES: { + return state.merge({ selected: action.payload }); + } + default: { return state; } diff --git a/app/state/reducers/ui/reservationsReducer.spec.js b/app/state/reducers/ui/reservationsReducer.spec.js index e6138abc1..ecfeccd74 100644 --- a/app/state/reducers/ui/reservationsReducer.spec.js +++ b/app/state/reducers/ui/reservationsReducer.spec.js @@ -20,6 +20,7 @@ import { selectReservationToEdit, selectReservationToShow, toggleTimeSlot, + setSelectedDatetimes } from 'actions/uiActions'; import Reservation from 'utils/fixtures/Reservation'; import { getTimeSlots } from 'utils/timeUtils'; @@ -776,4 +777,15 @@ describe('state/reducers/ui/reservationsReducer', () => { }); }); }); + + describe('UI.SET_SELECTED_DATETIMES', () => { + test('sets selected datetimes', () => { + const action = setSelectedDatetimes(['2019-01-01T00:00:00.000Z', '2019-02-01T00:00:00.000Z']); + const initialState = Immutable({ + selected: [], + }); + const nextState = reservationsReducer(initialState, action); + expect(nextState.selected).toStrictEqual(['2019-01-01T00:00:00.000Z', '2019-02-01T00:00:00.000Z']); + }); + }); }); diff --git a/app/utils/__tests__/timeUtils.spec.js b/app/utils/__tests__/timeUtils.spec.js index 01469f4e8..6d2102487 100644 --- a/app/utils/__tests__/timeUtils.spec.js +++ b/app/utils/__tests__/timeUtils.spec.js @@ -27,6 +27,8 @@ import { getEndTimeSlotWithMinPeriod, formatTime, formatDateTime, + formatDetailsDatetimes, + isMultiday } from 'utils/timeUtils'; const moment = extendMoment(Moment); @@ -703,6 +705,7 @@ describe('Utils: timeUtils', () => { test('returns correct string', () => { const beginString = '2021-11-20T08:30:00.000Z'; const endString = '2021-11-20T09:40:00.000Z'; + const endString2 = '2021-11-23T09:40:00.000Z'; const beginMoment = moment('2021-11-20 09:30Z'); const endMoment = moment('2021-11-20 09:35Z'); @@ -710,6 +713,8 @@ describe('Utils: timeUtils', () => { expect(getPrettifiedDuration(beginString, endMoment)).toBe('1h 5min'); expect(getPrettifiedDuration(beginMoment, endString)).toBe('10min'); expect(getPrettifiedDuration(beginMoment, endMoment)).toBe('5min'); + expect(getPrettifiedDuration(beginString, endString2)).toBe('3d 1h 10min'); + expect(getPrettifiedDuration(beginString, endString2, 'day')).toBe('3day 1h 10min'); }); }); @@ -719,6 +724,8 @@ describe('Utils: timeUtils', () => { expect(getPrettifiedPeriodUnits('2:00:00')).toBe('2h'); expect(getPrettifiedPeriodUnits('0:25:00')).toBe('25min'); expect(getPrettifiedPeriodUnits('0:00:00')).toBe(''); + expect(getPrettifiedPeriodUnits('2 1:00:00')).toBe('2d 1h'); + expect(getPrettifiedPeriodUnits('2 1:00:00', 'day')).toBe('2day 1h'); }); }); @@ -849,4 +856,24 @@ describe('Utils: timeUtils', () => { expect(formatDateTime(datetime, targetFormat)).toBe(expected); }); }); + + describe('formatDetailsDatetimes', () => { + test.each([ + ['2023-11-01T15:00:00Z', '2023-11-01T16:00:00Z', '1.11.2023 17:00–18:00 (1h)'], + ['2023-11-01T15:00:00Z', '2023-11-03T13:00:00Z', '1.11.2023 17:00 - 3.11.2023 15:00 (1d 22h)'], + ])('returns correctly formatted datetime string with given params', (begin, end, expected) => { + expect(formatDetailsDatetimes(begin, end)).toBe(expected); + }); + }); + + describe('isMultiday', () => { + test('returns true if begin and end are in different days', () => { + const begin = '2023-11-01T15:00:00Z'; + const end = '2023-11-03T13:00:00Z'; + const end2 = '2023-11-01T01:00:00Z'; + + expect(isMultiday(begin, end)).toBe(true); + expect(isMultiday(begin, end2)).toBe(false); + }); + }); }); diff --git a/app/utils/timeUtils.js b/app/utils/timeUtils.js index 5da9fb789..fceb8893c 100644 --- a/app/utils/timeUtils.js +++ b/app/utils/timeUtils.js @@ -239,9 +239,9 @@ function isValidDateString(dateString) { * @param {string|object} end time parsable by moment * @returns {string} e.g. '1h 30min', '2h' or '45min' */ -function getPrettifiedDuration(begin, end) { +function getPrettifiedDuration(begin, end, dayUnit = 'd') { const duration = moment.duration(moment(end).diff(moment(begin))); - return getPrettifiedPeriodUnits(duration); + return getPrettifiedPeriodUnits(duration, dayUnit); } /** @@ -249,16 +249,21 @@ function getPrettifiedDuration(begin, end) { * @param {string} period e.g. 1:30:00 * @returns {string} e.g. '1h 30min', '2h' or '45min' */ -function getPrettifiedPeriodUnits(period) { +function getPrettifiedPeriodUnits(period, dayUnit = 'd') { const duration = moment.duration(period); + const days = duration.days(); const hours = duration.hours(); const minutes = duration.minutes(); + const daysText = days > 0 ? `${days}${dayUnit}` : ''; const hoursText = hours > 0 ? `${hours}h` : ''; const minutesText = minutes > 0 ? `${minutes}min` : ''; - const spacer = hoursText && minutesText ? ' ' : ''; - return `${hoursText}${spacer}${minutesText}`; + // Spacer between days, hours, and minutes + const spacer1 = daysText && (hoursText || minutesText) ? ' ' : ''; + const spacer2 = hoursText && minutesText ? ' ' : ''; + + return `${daysText}${spacer1}${hoursText}${spacer2}${minutesText}`; } function prettifyHours(hours, showMinutes = false) { @@ -339,6 +344,37 @@ function formatDateTime(datetime, targetFormat) { return datetimeMoment.isValid() ? datetimeMoment.format(targetFormat) : datetime; } +/** + * Parses and formats given datetime into target format e.g. + * D.M.YYYY HH:mm–HH:mm (1h 30min) or D.M.YYYY HH:mm - D.M.YYYY HH:mm (2d 5h) + * @param {string} begin datetime + * @param {string} end datetime + * @returns {string} formatted datetime string + */ +function formatDetailsDatetimes(begin, end, dayUnit = 'd') { + if (isMultiday(begin, end)) { + const beginText = moment(begin).format('D.M.YYYY HH:mm'); + const endText = moment(end).format('D.M.YYYY HH:mm'); + const duration = getPrettifiedDuration(begin, end, dayUnit); + return `${beginText} - ${endText} (${duration})`; + } + + const beginText = moment(begin).format('D.M.YYYY HH:mm'); + const endText = moment(end).format('HH:mm'); + const duration = getPrettifiedDuration(begin, end, dayUnit); + return `${beginText}–${endText} (${duration})`; +} + +/** + * Check whether begin and end are on different days + * @param {string} begin datetime + * @param {string} end datetime + * @returns {boolean} true if begin and end are on different days + */ +function isMultiday(begin, end) { + return !moment(begin).isSame(moment(end), 'day'); +} + export { addToDate, calculateDuration, @@ -361,4 +397,6 @@ export { getTimeDiff, formatTime, formatDateTime, + formatDetailsDatetimes, + isMultiday, };