From bd8c3ec6190798cbe872b05d82699fd289acc53c Mon Sep 17 00:00:00 2001 From: SanttuA Date: Thu, 14 Sep 2023 12:08:44 +0300 Subject: [PATCH] Added a timeslot notification for maintenance mode (#267) Changes: - added custom error notification for timeslot selecting when maintenance mode is on - added redux state handling for maintenance mode reading and writing --- app/actions/announcementActions.js | 5 +++ app/constants/ActionTypes.js | 1 + app/i18n/messages/en.json | 1 + app/i18n/messages/fi.json | 1 + app/i18n/messages/sv.json | 1 + .../ReservationCalendarContainer.js | 3 ++ .../ReservationCalendarContainer.spec.js | 1 + .../reservationCalendarSelector.js | 2 ++ .../reservationCalendarSelector.spec.js | 11 +++++++ .../time-slots/TimeSlot.js | 9 +++++ .../time-slots/TimeSlot.spec.js | 16 +++++++++ .../time-slots/TimeSlots.js | 3 ++ .../time-slots/TimeSlots.spec.js | 2 ++ .../ServiceAnnouncement.js | 29 +++++++++++++--- .../tests/ServiceAnnouncement.spec.js | 14 ++++++-- app/state/reducers/ui/index.js | 2 ++ app/state/reducers/ui/maintenanceReducer.js | 23 +++++++++++++ .../reducers/ui/maintenanceReducer.spec.js | 33 +++++++++++++++++++ 18 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 app/actions/announcementActions.js create mode 100644 app/state/reducers/ui/maintenanceReducer.js create mode 100644 app/state/reducers/ui/maintenanceReducer.spec.js diff --git a/app/actions/announcementActions.js b/app/actions/announcementActions.js new file mode 100644 index 000000000..67e498eab --- /dev/null +++ b/app/actions/announcementActions.js @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions'; + +import types from 'constants/ActionTypes'; + +export const setMaintenanceMode = createAction(types.UI.SET_MAINTENANCE_MODE); diff --git a/app/constants/ActionTypes.js b/app/constants/ActionTypes.js index 1e38313ba..cd27ad08d 100644 --- a/app/constants/ActionTypes.js +++ b/app/constants/ActionTypes.js @@ -82,6 +82,7 @@ export default { SELECT_RESERVATION_TO_CANCEL: 'SELECT_RESERVATION_TO_CANCEL', SELECT_RESERVATION_TO_EDIT: 'SELECT_RESERVATION_TO_EDIT', SELECT_RESERVATION_TO_SHOW: 'SELECT_RESERVATION_TO_SHOW', + SET_MAINTENANCE_MODE: 'SET_MAINTENANCE_MODE', SHOW_RESERVATION_INFO_MODAL: 'SHOW_RESERVATION_INFO_MODAL', START_RESERVATION_EDIT_IN_INFO_MODAL: 'START_RESERVATION_EDIT_IN_INFO_MODAL', TOGGLE_RESOURCE_SHOW_MAP: 'TOGGLE_RESOURCE_SHOW_MAP', diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json index b40dff434..1ea35cf57 100644 --- a/app/i18n/messages/en.json +++ b/app/i18n/messages/en.json @@ -215,6 +215,7 @@ "NotFoundPage.searchPage": "If you are looking for a certain space, you can search for it through {searchPageLink}.", "NotFoundPage.searchPageLink": "search page", "NotFoundPage.title": "404 Page not found", + "Notifications.cannotReserveDuringMaintenance": "Cannot make reservations during maintenance.", "Notifications.errorMessage": "Something went wrong. Please try again in a moment.", "Notifications.loginErrorMessage": "Failed to login. Please try again in a moment.", "Notifications.userFetchErrorMessage": "Failed to retrieve user info. Please try again in a moment.", diff --git a/app/i18n/messages/fi.json b/app/i18n/messages/fi.json index b30b4819a..4cc2216c3 100644 --- a/app/i18n/messages/fi.json +++ b/app/i18n/messages/fi.json @@ -215,6 +215,7 @@ "NotFoundPage.searchPage": "Jos etsit jotain tiettyä tilaa, voit etsiä sitä {searchPageLink}.", "NotFoundPage.searchPageLink": "hakusivulta", "NotFoundPage.title": "404 Sivua ei löydy", + "Notifications.cannotReserveDuringMaintenance": "Huoltotyön aikana ei voi tehdä varauksia.", "Notifications.errorMessage": "Jotain meni vikaan. Yritä hetken päästä uudelleen.", "Notifications.loginErrorMessage": "Kirjautuminen epäonnistui. Yritä hetken päästä uudelleen.", "Notifications.userFetchErrorMessage": "Käyttäjätietojen hakeminen epäonnistui. Yritä hetken päästä uudelleen.", diff --git a/app/i18n/messages/sv.json b/app/i18n/messages/sv.json index 42b9204c3..281f71cae 100644 --- a/app/i18n/messages/sv.json +++ b/app/i18n/messages/sv.json @@ -217,6 +217,7 @@ "NotFoundPage.searchPage": "Om du letar efter ett särskilt utrymme, kan du använda dig av söksidan {searchPageLink}.", "NotFoundPage.searchPageLink": "söksidan", "NotFoundPage.title": "404 Webbplatsen hittades inte", + "Notifications.cannotReserveDuringMaintenance": "Det går inte att göra reservationer under underhåll.", "Notifications.errorMessage": "Ett fel uppstod. Försök på nytt om en liten stund.", "Notifications.loginErrorMessage": "Inloggning misslyckades. Försök igen om en liten stund.", "Notifications.userFetchErrorMessage": "Användarinformations hämtning misslyckades. Försök igen om en liten stund.", diff --git a/app/pages/resource/reservation-calendar/ReservationCalendarContainer.js b/app/pages/resource/reservation-calendar/ReservationCalendarContainer.js index 3c1c84f5a..9b3225dc3 100644 --- a/app/pages/resource/reservation-calendar/ReservationCalendarContainer.js +++ b/app/pages/resource/reservation-calendar/ReservationCalendarContainer.js @@ -57,6 +57,7 @@ export class UnconnectedReservationCalendarContainer extends Component { t: PropTypes.func.isRequired, time: PropTypes.string, timeSlots: PropTypes.array.isRequired, + isMaintenanceModeOn: PropTypes.bool.isRequired, }; getSelectedDateSlots = (timeSlots, selected) => { @@ -148,6 +149,7 @@ export class UnconnectedReservationCalendarContainer extends Component { t, time, timeSlots, + isMaintenanceModeOn, } = this.props; const isOpen = Boolean(timeSlots.length); @@ -164,6 +166,7 @@ export class UnconnectedReservationCalendarContainer extends Component { isEditing={isEditing} isFetching={isFetchingResource} isLoggedIn={isLoggedIn || resource.authentication === 'unauthenticated'} // count as logged in if no auth needed + isMaintenanceModeOn={isMaintenanceModeOn} isStaff={isStaff} isStrongAuthSatisfied={isStrongAuthSatisfied} onClear={actions.clearTimeSlots} diff --git a/app/pages/resource/reservation-calendar/ReservationCalendarContainer.spec.js b/app/pages/resource/reservation-calendar/ReservationCalendarContainer.spec.js index 6777d00bb..97d9c2245 100644 --- a/app/pages/resource/reservation-calendar/ReservationCalendarContainer.spec.js +++ b/app/pages/resource/reservation-calendar/ReservationCalendarContainer.spec.js @@ -70,6 +70,7 @@ describe('pages/resource/reservation-calendar/ReservationCalendarContainer', () resource, selected: [], timeSlots: [[TimeSlot.build()], [TimeSlot.build()]], + isMaintenanceModeOn: false, }; function getWrapper(props) { return shallowWithIntl(); diff --git a/app/pages/resource/reservation-calendar/reservationCalendarSelector.js b/app/pages/resource/reservation-calendar/reservationCalendarSelector.js index 80d0bc88a..2599e8e59 100644 --- a/app/pages/resource/reservation-calendar/reservationCalendarSelector.js +++ b/app/pages/resource/reservation-calendar/reservationCalendarSelector.js @@ -28,6 +28,7 @@ const resourceSelector = createResourceSelector(resourceIdSelector); const selectedSelector = state => state.ui.reservations.selected; const selectedReservationSlotSelector = state => state.ui.reservations.selectedSlot; const toEditSelector = state => state.ui.reservations.toEdit; +const isMaintenanceModeOnSelector = state => state.ui.maintenance.isMaintenanceModeOn; const isEditingSelector = createSelector( toEditSelector, @@ -107,6 +108,7 @@ const reservationCalendarSelector = createStructuredSelector({ selectedReservationSlot: selectedReservationSlotSelector, time: timeSelector, timeSlots: timeSlotsSelector, + isMaintenanceModeOn: isMaintenanceModeOnSelector, }); export default reservationCalendarSelector; diff --git a/app/pages/resource/reservation-calendar/reservationCalendarSelector.spec.js b/app/pages/resource/reservation-calendar/reservationCalendarSelector.spec.js index 3edfdb510..897c25abe 100644 --- a/app/pages/resource/reservation-calendar/reservationCalendarSelector.spec.js +++ b/app/pages/resource/reservation-calendar/reservationCalendarSelector.spec.js @@ -29,6 +29,9 @@ function getState(resource) { selectedSlot: { foo: 'bar' }, toEdit: ['mock-reservation'], }, + maintenance: { + isMaintenanceModeOn: false, + }, }), }; } @@ -108,6 +111,14 @@ describe('pages/resource/reservation-calendar/reservationCalendarSelector', () = expect(selected.isStrongAuthSatisfied).toBeDefined(); }); + test('returns isMaintenanceModeOn', () => { + const state = getState(resource); + const props = getProps(resource.id); + const selected = reservationCalendarSelector(state, props); + + expect(selected.isMaintenanceModeOn).toBeDefined(); + }); + test('returns isStaff', () => { const state = getState(resource); const props = getProps(resource.id); diff --git a/app/pages/resource/reservation-calendar/time-slots/TimeSlot.js b/app/pages/resource/reservation-calendar/time-slots/TimeSlot.js index f0a814164..0b41b7e16 100644 --- a/app/pages/resource/reservation-calendar/time-slots/TimeSlot.js +++ b/app/pages/resource/reservation-calendar/time-slots/TimeSlot.js @@ -30,6 +30,7 @@ class TimeSlot extends PureComponent { selected: PropTypes.bool.isRequired, slot: PropTypes.object.isRequired, t: PropTypes.func.isRequired, + isMaintenanceModeOn: PropTypes.bool.isRequired, }; static defaultProps = { @@ -134,10 +135,18 @@ class TimeSlot extends PureComponent { } getReservationInfoNotification(isLoggedIn, isStrongAuthSatisfied, resource, slot, t) { + const { isMaintenanceModeOn } = this.props; if (new Date(slot.end) < new Date() || slot.reserved) { return null; } + if (isMaintenanceModeOn) { + return { + message: t('Notifications.cannotReserveDuringMaintenance'), + type: 'info', + timeOut: 10000, + }; + } if (resource.reservable && !isStrongAuthSatisfied) { return { message: t('Notifications.loginToReserveStrongAuth'), diff --git a/app/pages/resource/reservation-calendar/time-slots/TimeSlot.spec.js b/app/pages/resource/reservation-calendar/time-slots/TimeSlot.spec.js index cd8de6612..4a3c56424 100644 --- a/app/pages/resource/reservation-calendar/time-slots/TimeSlot.spec.js +++ b/app/pages/resource/reservation-calendar/time-slots/TimeSlot.spec.js @@ -27,6 +27,7 @@ describe('pages/resource/reservation-calendar/time-slots/TimeSlot', () => { selected: false, showClear: false, slot: Immutable(TimeSlotFixture.build()), + isMaintenanceModeOn: false, }; function getWrapper(extraProps) { @@ -236,6 +237,21 @@ describe('pages/resource/reservation-calendar/time-slots/TimeSlot', () => { expect(t.callCount).toBe(0); }); + test('returns correct message when maintenance mode is on', () => { + const t = message => message; + const isMaintenanceModeOn = true; + const isStrongAuthSatisfied = false; + const resource = Resource.build({ reservable: true }); + const instance = getWrapper({ isMaintenanceModeOn }).instance(); + const result = instance.getReservationInfoNotification( + true, isStrongAuthSatisfied, resource, defaultProps.slot, t + ); + + expect(result.message).toBe(t('Notifications.cannotReserveDuringMaintenance')); + expect(result.type).toBe('info'); + expect(result.timeOut).toBe(10000); + }); + test('returns correct message when resource is reservable but not strong auth satisfied', () => { const t = message => message; const resource = Resource.build({ reservable: true }); diff --git a/app/pages/resource/reservation-calendar/time-slots/TimeSlots.js b/app/pages/resource/reservation-calendar/time-slots/TimeSlots.js index 968ee9926..81bccf04d 100644 --- a/app/pages/resource/reservation-calendar/time-slots/TimeSlots.js +++ b/app/pages/resource/reservation-calendar/time-slots/TimeSlots.js @@ -31,6 +31,7 @@ class TimeSlots extends Component { slots: PropTypes.array.isRequired, t: PropTypes.func.isRequired, time: PropTypes.string, + isMaintenanceModeOn: PropTypes.bool.isRequired, }; state = { @@ -219,6 +220,7 @@ class TimeSlots extends Component { selected, t, time, + isMaintenanceModeOn, } = this.props; const { hoveredTimeSlot } = this.state; if (!slot.end) { @@ -264,6 +266,7 @@ class TimeSlots extends Component { isEditing={isEditing} isHighlighted={isHighlighted} isLoggedIn={isLoggedIn} + isMaintenanceModeOn={isMaintenanceModeOn} isSelectable={isSelectable} isStrongAuthSatisfied={isStrongAuthSatisfied} isUnderMinPeriod={isUnderMinPeriod} diff --git a/app/pages/resource/reservation-calendar/time-slots/TimeSlots.spec.js b/app/pages/resource/reservation-calendar/time-slots/TimeSlots.spec.js index 1ef0eb100..b4c4aea56 100644 --- a/app/pages/resource/reservation-calendar/time-slots/TimeSlots.spec.js +++ b/app/pages/resource/reservation-calendar/time-slots/TimeSlots.spec.js @@ -54,6 +54,7 @@ describe('pages/resource/reservation-calendar/time-slots/TimeSlots', () => { ], selectedDate: '2016-10-10', slots: Immutable(defaultSlots), + isMaintenanceModeOn: false, }; function getWrapper(props) { @@ -152,6 +153,7 @@ describe('pages/resource/reservation-calendar/time-slots/TimeSlots', () => { expect(timeSlot.props().onClick).toBe(defaultProps.onClick); expect(timeSlot.props().resource).toEqual(defaultProps.resource); expect(timeSlot.props().slot).toEqual(defaultProps.slots[index][0]); + expect(timeSlot.props().isMaintenanceModeOn).toEqual(defaultProps.isMaintenanceModeOn); }); }); diff --git a/app/shared/service-announcement/ServiceAnnouncement.js b/app/shared/service-announcement/ServiceAnnouncement.js index 2c1e4d27a..a0b0751ea 100644 --- a/app/shared/service-announcement/ServiceAnnouncement.js +++ b/app/shared/service-announcement/ServiceAnnouncement.js @@ -2,10 +2,13 @@ import React from 'react'; import Alert from 'react-bootstrap/lib/Alert'; import Button from 'react-bootstrap/lib/Button'; import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import { injectT } from 'i18n'; import { fetchAnnouncement } from './serviceAnnouncementUtils'; import { getLocalizedFieldValue } from '../../utils/languageUtils'; +import { setMaintenanceMode } from '../../actions/announcementActions'; class ServiceAnnouncement extends React.Component { constructor(props) { @@ -20,10 +23,15 @@ class ServiceAnnouncement extends React.Component { componentDidMount() { fetchAnnouncement() - .then(data => this.setState({ - announcement: (data.results && data.results[0]) ? data.results[0] : null, - show: data.results && data.results.length > 0 - })) + .then(data => { + this.setState({ + announcement: (data.results && data.results[0]) ? data.results[0] : null, + show: data.results && data.results.length > 0 + }); + this.props.actions.setMaintenanceMode((data.results && data.results[0]) + ? data.results[0].is_maintenance_mode_on : false + ); + }) .catch(() => this.setState({ announcement: null, show: false })); } @@ -68,9 +76,20 @@ class ServiceAnnouncement extends React.Component { } ServiceAnnouncement.propTypes = { + actions: PropTypes.object.isRequired, contrast: PropTypes.string.isRequired, currentLanguage: PropTypes.string.isRequired, t: PropTypes.func.isRequired, }; -export default injectT(ServiceAnnouncement); +function mapDispatchToProps(dispatch) { + const actionCreators = { + setMaintenanceMode + }; + + return { actions: bindActionCreators(actionCreators, dispatch) }; +} + +export const ServiceAnnouncementWithT = injectT(ServiceAnnouncement); + +export default connect(null, mapDispatchToProps)(ServiceAnnouncementWithT); diff --git a/app/shared/service-announcement/tests/ServiceAnnouncement.spec.js b/app/shared/service-announcement/tests/ServiceAnnouncement.spec.js index 5c325cdce..6b4973b65 100644 --- a/app/shared/service-announcement/tests/ServiceAnnouncement.spec.js +++ b/app/shared/service-announcement/tests/ServiceAnnouncement.spec.js @@ -3,7 +3,7 @@ import { Button } from 'react-bootstrap'; import { shallowWithIntl } from 'utils/testUtils'; import { getLocalizedFieldValue } from '../../../utils/languageUtils'; -import ServiceAnnouncement from '../ServiceAnnouncement'; +import { ServiceAnnouncementWithT as ServiceAnnouncement } from '../ServiceAnnouncement'; import { fetchAnnouncement } from '../serviceAnnouncementUtils'; jest.mock('../serviceAnnouncementUtils', () => { @@ -12,7 +12,7 @@ jest.mock('../serviceAnnouncementUtils', () => { __esModule: true, ...originalModule, fetchAnnouncement: jest.fn(() => Promise.resolve({ - data: { results: [{ message: { fi: 'test' } }] }, + data: { results: [{ message: { fi: 'test' }, is_maintenance_mode_on: true }] }, })), }; }); @@ -20,7 +20,8 @@ jest.mock('../serviceAnnouncementUtils', () => { describe('shared/service-announcement/ServiceAnnouncement', () => { const defaultProps = { contrast: '', - currentLanguage: 'fi' + currentLanguage: 'fi', + actions: { setMaintenanceMode: jest.fn() } }; function getWrapper(extraProps) { @@ -106,6 +107,7 @@ describe('shared/service-announcement/ServiceAnnouncement', () => { describe('componentDidMount', () => { afterEach(() => { fetchAnnouncement.mockClear(); + defaultProps.actions.setMaintenanceMode.mockClear(); }); test('calls fetchAnnouncement and setState', async () => { @@ -115,6 +117,12 @@ describe('shared/service-announcement/ServiceAnnouncement', () => { expect(fetchAnnouncement).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1); }); + + test('calls props.actions.setMaintenanceMode', async () => { + const instance = getWrapper().instance(); + await instance.componentDidMount(); + expect(defaultProps.actions.setMaintenanceMode).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/app/state/reducers/ui/index.js b/app/state/reducers/ui/index.js index f616bd009..60904c5cb 100644 --- a/app/state/reducers/ui/index.js +++ b/app/state/reducers/ui/index.js @@ -9,6 +9,7 @@ import searchReducer from './searchReducer'; import accessibilityReducer from './accessibilityReducer'; import favoritesPagesReducer from './favoritesPageReducer'; import manageReservationsPageReducer from './manageReservationsPageReducer'; +import maintenanceReducer from './maintenanceReducer'; const uiReducers = combineReducers({ modals: modalsReducer, @@ -24,6 +25,7 @@ const uiReducers = combineReducers({ resourceMap: resourceMapReducer, search: searchReducer, accessibility: accessibilityReducer, + maintenance: maintenanceReducer, }); export default uiReducers; diff --git a/app/state/reducers/ui/maintenanceReducer.js b/app/state/reducers/ui/maintenanceReducer.js new file mode 100644 index 000000000..617290f66 --- /dev/null +++ b/app/state/reducers/ui/maintenanceReducer.js @@ -0,0 +1,23 @@ +import Immutable from 'seamless-immutable'; + +import types from 'constants/ActionTypes'; + +const initialState = Immutable({ + isMaintenanceModeOn: false, +}); + +function maintenanceReducer(state = initialState, action) { + switch (action.type) { + case types.UI.SET_MAINTENANCE_MODE: { + return state.merge({ + isMaintenanceModeOn: action.payload + }); + } + + default: { + return state; + } + } +} + +export default maintenanceReducer; diff --git a/app/state/reducers/ui/maintenanceReducer.spec.js b/app/state/reducers/ui/maintenanceReducer.spec.js new file mode 100644 index 000000000..d4e8d55f6 --- /dev/null +++ b/app/state/reducers/ui/maintenanceReducer.spec.js @@ -0,0 +1,33 @@ + +import Immutable from 'seamless-immutable'; + +import types from 'constants/ActionTypes'; +import reducer from './maintenanceReducer'; + +describe('/state/reducers/ui/maintenanceReducer', () => { + const initialState = Immutable({ + isMaintenanceModeOn: false + }); + + const action = { + type: types.UI.SET_MAINTENANCE_MODE, + payload: true + }; + + const expectedState = { isMaintenanceModeOn: true }; + + test('initialState is correct', () => { + const actual = reducer(undefined, { type: 'nothing' }); + expect(actual).toEqual(initialState); + }); + + test('returns correct state on SET_MAINTENANCE_MODE', () => { + const actual = reducer(undefined, action); + expect(actual).toEqual(expectedState); + }); + + test('returns correct state if no matching action type is given', () => { + const actual = reducer(undefined, { type: types.API.RESOURCES_GET_SUCCESS }); + expect(actual).toEqual(initialState); + }); +});