From 8e0e08aeb17020c6a8cbb6705a5827d93321eb38 Mon Sep 17 00:00:00 2001 From: HotStew <31072611+HotStew@users.noreply.github.com> Date: Thu, 28 Apr 2022 15:05:43 +0300 Subject: [PATCH] Reservation rate excel report (#338) * Create Reservation rate report api action Create the action for fetching the reservation rate report excel. Also add extensions for filenames for all report actions and modify the downloadReport function to not append .docx to filename anymore. This had to be done because all previous reports are .docx but this new one is xlsx. * Conditional datepicker rendering in DateTimeRange Conditionally render datepicker in DateTimeRange component. Defaults to always rendering it unless specifying not to render it in controlprops. The Time range part of the DateTimeRange component is nice and useful, so now basically you are able to just render that part. * Reservation Rate Report actions, reducers, selectors Create actions reducers and selectors for Modal. * Create reservation rate report components Create component ReservationRateReportModal. This modal lies in the /reservations page and can be unhidden/shown in the dropdown of -> Varausasteraportti. This modal will take the basic props the modal itself requires along with units and the action which downloads the report. Within the modal there are fields where the user can select/ input data: Units, Date range and Time range. Unit seletion field is a Typeahead component which is also a new package. Lastly there is the download button which when clicked, will call the api action to download the report. * Fix errors Errors were mostly due to incorrect proptyping * Project conf Install node-sass that is compatible with node version 14.X.X Add a css loader for testing. Co-authored-by: Kevin Seestrand --- app/actions/uiActions.js | 3 + app/api/actionTypes.js | 1 + app/api/actions/index.js | 2 + app/api/actions/reports.js | 16 +- app/api/actions/utils.js | 2 +- app/pages/AppContainer.js | 2 + app/shared/_shared.scss | 1 + .../AvailabilityViewResourceInfoContainer.js | 2 +- .../AvailabilityTimeline/Reservation.js | 4 +- app/shared/form-fields/DateTimeRange.js | 25 +-- app/shared/form-fields/DateTimeRange.spec.js | 29 ++- .../ReservationsRateReport.js | 169 ++++++++++++++++++ .../ReservationsRateReport.spec.js | 97 ++++++++++ .../ReservationsRateReportSelector.js | 29 +++ .../ReservationsRateReportSelector.spec.js | 39 ++++ .../_reservation-rate-report.scss | 57 ++++++ .../modals/reservation-rate-report/index.js | 3 + .../ReservationsReportButton.js | 14 +- .../ReservationsReportButton.spec.js | 15 +- app/state/reducers/index.js | 2 + .../reservationsRateReportModalReducer.js | 54 ++++++ ...reservationsRateReportModalReducer.spec.js | 84 +++++++++ config/webpack.tests.js | 4 + package.json | 7 +- 24 files changed, 628 insertions(+), 33 deletions(-) create mode 100644 app/shared/modals/reservation-rate-report/ReservationsRateReport.js create mode 100644 app/shared/modals/reservation-rate-report/ReservationsRateReport.spec.js create mode 100644 app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.js create mode 100644 app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.spec.js create mode 100644 app/shared/modals/reservation-rate-report/_reservation-rate-report.scss create mode 100644 app/shared/modals/reservation-rate-report/index.js create mode 100644 app/state/reducers/reservationsRateReportModalReducer.js create mode 100644 app/state/reducers/reservationsRateReportModalReducer.spec.js diff --git a/app/actions/uiActions.js b/app/actions/uiActions.js index c7044957..a7d5eb68 100644 --- a/app/actions/uiActions.js +++ b/app/actions/uiActions.js @@ -3,6 +3,7 @@ import { createActions } from 'redux-actions'; export default createActions( 'CHANGE_RESERVATION_SEARCH_FILTERS', 'CHANGE_RESOURCE_SEARCH_FILTERS', + 'CHANGE_RESERVATIONS_RATE_REPORT_MODAL_FILTERS', 'CLEAR_RESOURCE_SELECTOR', 'HIDE_RESERVATION_CANCEL_MODAL', 'HIDE_RESERVATION_INFO_MODAL', @@ -10,10 +11,12 @@ export default createActions( 'HIDE_RESOURCE_INFO_MODAL', 'HIDE_RESOURCE_IMAGES_MODAL', 'HIDE_RESOURCE_SELECTOR_MODAL', + 'HIDE_RESERVATIONS_RATE_REPORT_MODAL', 'SHOW_RESERVATION_CANCEL_MODAL', 'SHOW_RESERVATION_INFO_MODAL', 'SHOW_RESERVATION_SUCCESS_MODAL', 'SHOW_RESOURCE_INFO_MODAL', 'SHOW_RESOURCE_IMAGES_MODAL', 'SHOW_RESOURCE_SELECTOR_MODAL', + 'SHOW_RESERVATIONS_RATE_REPORT_MODAL', ); diff --git a/app/api/actionTypes.js b/app/api/actionTypes.js index bd1205c6..e2751184 100644 --- a/app/api/actionTypes.js +++ b/app/api/actionTypes.js @@ -21,6 +21,7 @@ export default Object.assign( create('RESERVATION', ['GET', 'DELETE', 'POST', 'PUT']), create('RESERVATION_DETAILS_REPORT', ['GET']), create('RESERVATIONS_REPORT', ['GET']), + create('RESERVATIONS_RATE_REPORT', ['GET']), create('RESERVATIONS', ['GET']), create('RESOURCE', ['GET']), create('RESOURCE_DAILY_REPORT', ['GET']), diff --git a/app/api/actions/index.js b/app/api/actions/index.js index b995f1cc..79135550 100644 --- a/app/api/actions/index.js +++ b/app/api/actions/index.js @@ -13,6 +13,7 @@ import { fetchReservationDetailsReport, fetchReservationsReport, fetchResourceDailyReport, + fetchReservationsRateReport, } from './reports'; import { cancelReservation, @@ -48,6 +49,7 @@ export { fetchReservationDetailsReport, fetchReservationsReport, fetchResourceDailyReport, + fetchReservationsRateReport, fetchReservation, fetchReservations, fetchResource, diff --git a/app/api/actions/reports.js b/app/api/actions/reports.js index 018e57d3..fd897d39 100644 --- a/app/api/actions/reports.js +++ b/app/api/actions/reports.js @@ -6,7 +6,7 @@ function fetchResourceDailyReport({ date, resourceIds }) { const day = moment(date).format('YYYY-MM-DD'); return createReportAction({ endpoint: 'daily_reservations', - filename: `paivaraportti-${day}`, + filename: `paivaraportti-${day}.docx`, type: 'RESOURCE_DAILY_REPORT', params: { resource: resourceIds.join(','), @@ -19,7 +19,7 @@ function fetchResourceDailyReport({ date, resourceIds }) { function fetchReservationDetailsReport(reservationId) { return createReportAction({ endpoint: 'reservation_details', - filename: `varaus-${reservationId}`, + filename: `varaus-${reservationId}.docx`, type: 'RESERVATION_DETAILS_REPORT', params: { reservation: reservationId, @@ -30,14 +30,24 @@ function fetchReservationDetailsReport(reservationId) { function fetchReservationsReport(filters) { return createReportAction({ endpoint: 'reservation_details', - filename: 'varaukset', + filename: 'varaukset.docx', type: 'RESERVATIONS_REPORT', params: filters, }); } +function fetchReservationsRateReport(filters) { + return createReportAction({ + endpoint: 'reservation_rate', + filename: 'varausasteraportti.xlsx', + type: 'RESERVATIONS_RATE_REPORT', + params: filters, + }); +} + export { fetchReservationDetailsReport, fetchResourceDailyReport, fetchReservationsReport, + fetchReservationsRateReport, }; diff --git a/app/api/actions/utils.js b/app/api/actions/utils.js index e5c25d6b..9f9eb371 100644 --- a/app/api/actions/utils.js +++ b/app/api/actions/utils.js @@ -98,7 +98,7 @@ function getSuccessTypeDescriptor(type, options = {}) { function downloadReport(response, filename) { response.blob().then((blob) => { - fileSaver.saveAs(blob, `${filename}.docx`); + fileSaver.saveAs(blob, filename); }); } diff --git a/app/pages/AppContainer.js b/app/pages/AppContainer.js index b5ddc47a..bd6300d1 100644 --- a/app/pages/AppContainer.js +++ b/app/pages/AppContainer.js @@ -15,6 +15,7 @@ import { import { fetchAuthState } from 'auth/actions'; import ReservationCancelModal from 'shared/modals/reservation-cancel'; import ReservationInfoModal from 'shared/modals/reservation-info'; +import ReservationsRateReportModal from 'shared/modals/reservation-rate-report'; import ReservationSuccessModal from 'shared/modals/reservation-success'; import ResourceImagesModal from 'shared/modals/resource-images'; import ResourceInfoModal from 'shared/modals/resource-info'; @@ -55,6 +56,7 @@ export class UnconnectedAppContainer extends Component { + diff --git a/app/shared/_shared.scss b/app/shared/_shared.scss index d70973a3..f3ee03cd 100644 --- a/app/shared/_shared.scss +++ b/app/shared/_shared.scss @@ -11,6 +11,7 @@ @import './modals/reservation-info/reservation-info'; @import './modals/reservation-success/reservation-success'; @import './modals/resource-selector/resource-selector'; +@import './modals/reservation-rate-report/reservation-rate-report'; @import './navbar/navbar'; @import './recurring-reservation-controls/recurring-reservation-controls'; @import './resource-info-button/resource-info-button'; diff --git a/app/shared/availability-view/Sidebar/GroupInfo/AvailabilityViewResourceInfo/AvailabilityViewResourceInfoContainer.js b/app/shared/availability-view/Sidebar/GroupInfo/AvailabilityViewResourceInfo/AvailabilityViewResourceInfoContainer.js index 762f3e93..484b5f87 100644 --- a/app/shared/availability-view/Sidebar/GroupInfo/AvailabilityViewResourceInfo/AvailabilityViewResourceInfoContainer.js +++ b/app/shared/availability-view/Sidebar/GroupInfo/AvailabilityViewResourceInfo/AvailabilityViewResourceInfoContainer.js @@ -12,7 +12,7 @@ AvailabilityViewResourceInfo.propTypes = { isFavorite: PropTypes.bool.isRequired, isHighlighted: PropTypes.bool, name: PropTypes.string.isRequired, - peopleCapacity: PropTypes.number.isRequired, + peopleCapacity: PropTypes.number, }; export function AvailabilityViewResourceInfo(props) { return ( diff --git a/app/shared/availability-view/TimelineGroups/TimelineGroup/AvailabilityTimeline/Reservation.js b/app/shared/availability-view/TimelineGroups/TimelineGroup/AvailabilityTimeline/Reservation.js index 6b296206..daa8fd7b 100644 --- a/app/shared/availability-view/TimelineGroups/TimelineGroup/AvailabilityTimeline/Reservation.js +++ b/app/shared/availability-view/TimelineGroups/TimelineGroup/AvailabilityTimeline/Reservation.js @@ -11,8 +11,8 @@ import Link from './Link'; Reservation.propTypes = { begin: PropTypes.string.isRequired, end: PropTypes.string.isRequired, - visualBegin: PropTypes.object, - visualEnd: PropTypes.object, + visualBegin: PropTypes.string, + visualEnd: PropTypes.string, eventSubject: PropTypes.string, id: PropTypes.number.isRequired, numberOfParticipants: PropTypes.number, diff --git a/app/shared/form-fields/DateTimeRange.js b/app/shared/form-fields/DateTimeRange.js index d3cd1fea..bacbc55e 100644 --- a/app/shared/form-fields/DateTimeRange.js +++ b/app/shared/form-fields/DateTimeRange.js @@ -12,6 +12,7 @@ export default class DateTimeRange extends React.Component { onBlur: PropTypes.func, onChange: PropTypes.func.isRequired, required: PropTypes.bool, + renderDatePicker: PropTypes.bool, value: PropTypes.shape({ begin: PropTypes.object.isRequired, end: PropTypes.object.isRequired, @@ -55,6 +56,8 @@ export default class DateTimeRange extends React.Component { render() { const value = this.props.controlProps.value; + const renderDatePicker = this.props.controlProps.renderDatePicker === undefined + ? true : this.props.controlProps.renderDatePicker; const requiredPostfix = this.props.controlProps.required ? '*' : ''; return (
- + {renderDatePicker === true ? ( + + ) : null} null, + value: { begin: {}, end: {} }, + }, + id: '1234', + noLabels: false, +}; + function getWrapper(props) { - const defaults = { - controlProps: { - onChange: () => null, - value: { begin: {}, end: {} }, - }, - id: '1234', - noLabels: false, - }; return shallow(); } @@ -39,6 +40,18 @@ describe('shared/form-fields/DateTimeRange', () => { expect(fields.at(2).prop('componentClass')).to.equal(Time); }); + it('does not render datepicker if specified', () => { + const fields = getWrapper({ + controlProps: { + ...defaults.controlProps, + renderDatePicker: false, + }, + }).find(Field); + expect(fields).to.have.length(2); + expect(fields.at(0).prop('componentClass')).to.equal(Time); + expect(fields.at(1).prop('componentClass')).to.equal(Time); + }); + it('renders labels only when noLabels = false', () => { const fields = getWrapper().find(Field); expect(fields.at(0).prop('label')).to.equal('Päivä'); diff --git a/app/shared/modals/reservation-rate-report/ReservationsRateReport.js b/app/shared/modals/reservation-rate-report/ReservationsRateReport.js new file mode 100644 index 00000000..3125898e --- /dev/null +++ b/app/shared/modals/reservation-rate-report/ReservationsRateReport.js @@ -0,0 +1,169 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import FontAwesome from 'react-fontawesome'; +import Button from 'react-bootstrap/lib/Button'; +import Modal from 'react-bootstrap/lib/Modal'; +import Row from 'react-bootstrap/lib/Row'; +import FormGroup from 'react-bootstrap/lib/FormGroup'; +import ControlLabel from 'react-bootstrap/lib/ControlLabel'; +import { Typeahead } from 'react-bootstrap-typeahead'; + +import 'react-bootstrap-typeahead/css/Typeahead-bs4.css'; +import 'react-bootstrap-typeahead/css/Typeahead.css'; + +import DatePicker from 'shared/date-picker'; +import DateTimeRange from 'shared/form-fields/DateTimeRange'; +import { fetchReservationsRateReport } from 'api/actions'; +import uiActions from 'actions/uiActions'; +import Selector from './ReservationsRateReportSelector'; + +export class ReservationsRateReportModal extends Component { + static propTypes = { + onHide: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, + units: PropTypes.object.isRequired, + fetchReservationsRateReport: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, + errorMessage: PropTypes.string.isRequired, + filters: PropTypes.shape({ + unitSelections: PropTypes.array.isRequired, + times: PropTypes.object.isRequired, + dateStart: PropTypes.string.isRequired, + dateEnd: PropTypes.string.isRequired, + }).isRequired, + }; + + constructor(props) { + super(props); + this.downloadReport = this.downloadReport.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + handleChange(filter) { + this.props.onChange(filter); + } + + downloadReport() { + const { + unitSelections, + dateStart, + dateEnd, + times, + } = this.props.filters; + + this.props.fetchReservationsRateReport({ + start_date: dateStart, + end_date: dateEnd, + start_time: times.begin.time, + end_time: times.end.time, + units: unitSelections.map(selection => selection.value), + }); + } + + render() { + const { + onHide, + show, + units, + loading, + errorMessage, + } = this.props; + + const { + dateStart, + dateEnd, + times, + } = this.props.filters; + + const unitOptions = Object.keys(units).map((id) => { + const unit = units[id]; + return { value: unit.id, label: unit.name.fi }; + }); + + return ( + +
+ +

Varausasteraportti

+
+ +
+ + + this.handleChange({ unitSelections: selected })} + className="unit-multi-select" + /> + + + + +
+ Varaukset aikavälillä + this.handleChange({ dateStart: start })} + value={dateStart} + className="date-picker-field" + /> +
+ +
+ this.handleChange({ dateEnd: end })} + value={dateEnd} + className="date-picker-field" + /> +
+
+
+ + +
+ this.handleChange({ times: time }), + value: times, + }} + /> +
+
+
+
+
+ + + { errorMessage } + +
+
+ ); + } +} + +const actions = { + onHide: uiActions.hideReservationsRateReportModal, + onChange: uiActions.changeReservationsRateReportModalFilters, + fetchReservationsRateReport, +}; + +export default connect(Selector, actions)( + ReservationsRateReportModal +); diff --git a/app/shared/modals/reservation-rate-report/ReservationsRateReport.spec.js b/app/shared/modals/reservation-rate-report/ReservationsRateReport.spec.js new file mode 100644 index 00000000..e47f3651 --- /dev/null +++ b/app/shared/modals/reservation-rate-report/ReservationsRateReport.spec.js @@ -0,0 +1,97 @@ +import { expect } from 'chai'; +import { shallow } from 'enzyme'; +import moment from 'moment'; +import React from 'react'; +import Modal from 'react-bootstrap/lib/Modal'; +import simple from 'simple-mock'; + +import { ReservationsRateReportModal } from './ReservationsRateReport'; + +describe('shared/modals/reservation-rate-report/ReservationsRateReport', () => { + const defaults = { + fetchReservationsRateReport: () => null, + onHide: () => {}, + onChange: () => null, + show: true, + units: { + abc: { + name: { fi: 'Palaveri' }, + id: 'u-1', + }, + }, + errorMessage: '', + loading: false, + filters: { + dateStart: '2022-02-02', + dateEnd: '2022-02-01', + times: { + begin: { time: '12:00' }, + end: { time: '13:00' }, + }, + unitSelections: [], + }, + }; + + function getWrapper(props) { + return shallow(); + } + + it('passes props to Modal', () => { + const instance = getWrapper().instance(); + expect(instance.props.onHide).to.equal(defaults.onHide); + expect(instance.props.onChange).to.equal(defaults.onChange); + expect(instance.props.fetchReservationsRateReport) + .to.equal(defaults.fetchReservationsRateReport); + expect(instance.props.show).to.equal(defaults.show); + expect(instance.props.loading).to.equal(defaults.loading); + expect(instance.props.units).to.eql(defaults.units); + expect(instance.props.errorMessage).to.equal(defaults.errorMessage); + expect(instance.props.filters).to.eql(defaults.filters); + }); + + describe('render', () => { + it('modal', () => { + expect(getWrapper().is(Modal)).to.be.true; + }); + it('datepickers', () => { + expect(getWrapper().find('.date-picker-field')).to.have.lengthOf(2); + }); + it('timepicker', () => { + expect(getWrapper().find('.reservation-time-range')).to.have.lengthOf(1); + }); + it('unit selection', () => { + expect(getWrapper().find('.unit-multi-select')).to.have.lengthOf(1); + }); + it('download button', () => { + expect(getWrapper().find('.download-button')).to.have.lengthOf(1); + }); + }); + + describe('fields have correct data', () => { + it('unit selection has correct unit options', () => { + const unitSelect = getWrapper().find('.unit-multi-select'); + expect(unitSelect.prop('options')).to.eql([{ value: 'u-1', label: 'Palaveri' }]); + }); + }); + + describe('onChange', () => { + it('calls onChange with updated filter', () => { + const onChange = simple.mock(); + const instance = getWrapper({ onChange }).instance(); + const filters = { + dateStart: moment('2021-1-1'), + }; + instance.handleChange(filters); + expect(onChange.callCount).to.equal(1); + expect(onChange.lastCall.arg).to.deep.equal(filters); + }); + }); + + describe('report download', () => { + it('calls fetchreservationreport on download button click', () => { + const fetchReservationsRateReport = simple.mock(); + getWrapper({ fetchReservationsRateReport }).find('.download-button').simulate('click'); + expect(fetchReservationsRateReport.callCount).to.equal(1); + }); + }); +}); diff --git a/app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.js b/app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.js new file mode 100644 index 00000000..d7bf9390 --- /dev/null +++ b/app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.js @@ -0,0 +1,29 @@ +import { createStructuredSelector } from 'reselect'; + +function showSelector(state) { + return state.modals.reservationsRateReport.show; +} + +function unitsSelector(state) { + return state.data.units; +} + +function filtersSelector(state) { + return state.modals.reservationsRateReport.filters; +} + +function errorMessageSelector(state) { + return state.modals.reservationsRateReport.errorMessage; +} + +function loadingSelector(state) { + return state.modals.reservationsRateReport.loading; +} + +export default createStructuredSelector({ + show: showSelector, + units: unitsSelector, + loading: loadingSelector, + filters: filtersSelector, + errorMessage: errorMessageSelector, +}); diff --git a/app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.spec.js b/app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.spec.js new file mode 100644 index 00000000..1f086676 --- /dev/null +++ b/app/shared/modals/reservation-rate-report/ReservationsRateReportSelector.spec.js @@ -0,0 +1,39 @@ +import { expect } from 'chai'; + +import { getState } from 'utils/testUtils'; +import selector from './ReservationsRateReportSelector'; + +describe('shared/modals/reservation-rate-report/ReservationsRateReportSelector', () => { + const units = { + 'u-1': { id: 'u-1', name: { fi: 'Unit 1' } }, + 'u-2': { id: 'u-2', name: { fi: 'Unit 2' } }, + }; + + describe('show', () => { + it('returns true if ReservationsRateReport modal show is true', () => { + const state = getState({ + 'modals.reservationsRateReport': { show: true }, + }); + const actual = selector(state).show; + expect(actual).to.be.true; + }); + + it('returns false if ReservationsRateReport modal show is false', () => { + const state = getState({ + 'modals.reservationsRateReport': { show: false }, + }); + const actual = selector(state).show; + expect(actual).to.be.false; + }); + }); + + describe('units', () => { + it('returns units', () => { + const state = getState({ + 'data.units': units, + }); + const actual = selector(state).units; + expect(actual).to.eql(state.data.units); + }); + }); +}); diff --git a/app/shared/modals/reservation-rate-report/_reservation-rate-report.scss b/app/shared/modals/reservation-rate-report/_reservation-rate-report.scss new file mode 100644 index 00000000..6e8563e3 --- /dev/null +++ b/app/shared/modals/reservation-rate-report/_reservation-rate-report.scss @@ -0,0 +1,57 @@ +.reservations-rate-report-modal { + .modal-title { + color: $black; + } + .modal-body { + box-sizing: border-box; + padding: 15px; + width: 100%; + + .date-pickers-container { + display: flex; + width: 100%; + + .delimiter { + display: flex; + align-items: center; + padding: 0 8px; + font-size: 32px; + } + } + + .unit-multi-select { + .rbt-input-wrapper { + padding: 0 5px 0 5px; + } + } + + .time-picker-container { + display: flex; + width: 100%; + } + + @include input-size( + '.date-picker', + $input-height-base, + $padding-base-vertical, + $padding-base-horizontal, + $font-size-base, + $line-height-base, + $input-border-radius + ); + + .date-picker { + border: 2px solid $input-border; + background-color: transparent; + + input { + padding: 0; + background-color: transparent; + } + } + } + + .download-button { + float: left; + } +} diff --git a/app/shared/modals/reservation-rate-report/index.js b/app/shared/modals/reservation-rate-report/index.js new file mode 100644 index 00000000..5596f53a --- /dev/null +++ b/app/shared/modals/reservation-rate-report/index.js @@ -0,0 +1,3 @@ +import ReservationsRateReport from './ReservationsRateReport'; + +export default ReservationsRateReport; diff --git a/app/shared/reservations-report-button/ReservationsReportButton.js b/app/shared/reservations-report-button/ReservationsReportButton.js index 9b1e2f3c..16b22377 100644 --- a/app/shared/reservations-report-button/ReservationsReportButton.js +++ b/app/shared/reservations-report-button/ReservationsReportButton.js @@ -4,13 +4,17 @@ import DropdownButton from 'react-bootstrap/lib/DropdownButton'; import MenuItem from 'react-bootstrap/lib/MenuItem'; import { fetchReservationsReport } from 'api/actions'; +import uiActions from 'actions/uiActions'; ReservationsReportButton.propTypes = { onClick: PropTypes.func.isRequired, searchFilters: PropTypes.object.isRequired, + showReservationsRateReportModal: PropTypes.func.isRequired, }; -export function ReservationsReportButton({ onClick, searchFilters }) { +export function ReservationsReportButton({ + onClick, showReservationsRateReportModal, searchFilters, +}) { return (
onClick(searchFilters)} > Varausraportti + + Varausasteraportti +
); } -const actions = { onClick: fetchReservationsReport }; +const actions = { + onClick: fetchReservationsReport, + showReservationsRateReportModal: uiActions.showReservationsRateReportModal, +}; export default connect(null, actions)(ReservationsReportButton); diff --git a/app/shared/reservations-report-button/ReservationsReportButton.spec.js b/app/shared/reservations-report-button/ReservationsReportButton.spec.js index 2a7b0e55..c32b6baf 100644 --- a/app/shared/reservations-report-button/ReservationsReportButton.spec.js +++ b/app/shared/reservations-report-button/ReservationsReportButton.spec.js @@ -10,6 +10,7 @@ import { ReservationsReportButton } from './ReservationsReportButton'; describe('shared/reservation-report-button/ReservationsReportButton', () => { const defaultProps = { onClick: () => null, + showReservationsRateReportModal: () => null, searchFilters: { start: '2017-11-29', end: '2017-11-30', @@ -30,10 +31,12 @@ describe('shared/reservation-report-button/ReservationsReportButton', () => { expect(button.prop('title')).to.equal('Lataa raportti'); }); - describe('daily report item', () => { + describe('report items', () => { it('has correct text', () => { - const item = getWrapper().find(MenuItem).at(0); - expect(item.children().text()).to.equal('Varausraportti'); + const item1 = getWrapper().find(MenuItem).at(0); + const item2 = getWrapper().find(MenuItem).at(1); + expect(item1.children().text()).to.equal('Varausraportti'); + expect(item2.children().text()).to.equal('Varausasteraportti'); }); it('calls onClick action on click', () => { @@ -43,5 +46,11 @@ describe('shared/reservation-report-button/ReservationsReportButton', () => { expect(onClick.callCount).to.equal(1); expect(onClick.lastCall.arg).to.deep.equal(defaultProps.searchFilters); }); + it('calls showReservationsRateReportModal action on click', () => { + const showReservationsRateReportModal = simple.mock(); + const item = getWrapper({ showReservationsRateReportModal }).find(MenuItem).at(1); + item.simulate('click'); + expect(showReservationsRateReportModal.callCount).to.equal(1); + }); }); }); diff --git a/app/state/reducers/index.js b/app/state/reducers/index.js index 36452a61..e61f29c5 100644 --- a/app/state/reducers/index.js +++ b/app/state/reducers/index.js @@ -9,6 +9,7 @@ import recurringReservations from './recurringReservationsReducer'; import resourcePage from './resourcePageReducer'; import reservationCancel from './reservationCancelModalReducer'; import reservationInfo from './reservationInfoModalReducer'; +import reservationsRateReport from './reservationsRateReportModalReducer'; import reservationSearchFilters from './reservationSearchFiltersReducer'; import reservationSearchResults from './reservationSearchResultsReducer'; import reservationSuccess from './reservationSuccessModalReducer'; @@ -32,6 +33,7 @@ export default combineReducers({ resourceImages, resourceInfo, resourceSelector: resourceSelectorModal, + reservationsRateReport, }), recurringReservations, reservationSearchPage: combineReducers({ diff --git a/app/state/reducers/reservationsRateReportModalReducer.js b/app/state/reducers/reservationsRateReportModalReducer.js new file mode 100644 index 00000000..1c787048 --- /dev/null +++ b/app/state/reducers/reservationsRateReportModalReducer.js @@ -0,0 +1,54 @@ +import moment from 'moment'; +import { handleActions } from 'redux-actions'; + +import uiActions from 'actions/uiActions'; +import actionTypes from 'api/actionTypes'; + +const initialState = { + units: [], + show: false, + loading: false, + errorMessage: '', + filters: { + unitSelections: [], + dateStart: moment().subtract(30, 'days').format('YYYY-MM-DD'), + dateEnd: moment().format('YYYY-MM-DD'), + times: { + begin: { time: '08:00' }, + end: { time: '16:00' }, + }, + }, +}; + +const hide = () => initialState; + +const show = state => ({ + ...state, + show: true, +}); + +export default handleActions({ + [uiActions.showReservationsRateReportModal]: show, + [uiActions.hideReservationsRateReportModal]: hide, + [uiActions.changeReservationsRateReportModalFilters]: (state, action) => ({ + ...state, + filters: { + ...state.filters, + ...action.payload, + }, + }), + [actionTypes.RESERVATIONS_RATE_REPORT_GET_REQUEST]: state => ({ + ...state, + loading: true, + }), + [actionTypes.RESERVATIONS_RATE_REPORT_GET_ERROR]: (state, action) => ({ + ...state, + errorMessage: action.payload.response[0], + loading: false, + }), + [actionTypes.RESERVATIONS_RATE_REPORT_GET_SUCCESS]: state => ({ + ...state, + errorMessage: '', + loading: false, + }), +}, initialState); diff --git a/app/state/reducers/reservationsRateReportModalReducer.spec.js b/app/state/reducers/reservationsRateReportModalReducer.spec.js new file mode 100644 index 00000000..f130ea0c --- /dev/null +++ b/app/state/reducers/reservationsRateReportModalReducer.spec.js @@ -0,0 +1,84 @@ +import { expect } from 'chai'; +import { createAction } from 'redux-actions'; + +import actionTypes from 'api/actionTypes'; +import reservationsRateReportModalReducer from './reservationsRateReportModalReducer'; + +describe('state/reducers/reservationSearchResultsReducer', () => { + describe('handling actions', () => { + describe('RESERVATIONS_RATE_REPORT_GET_REQUEST', () => { + const request = createAction(actionTypes.RESERVATIONS_RATE_REPORT_GET_REQUEST); + + it('sets correct loading status', () => { + const action = request(); + const currentState = { + loading: false, + }; + const nextState = reservationsRateReportModalReducer(currentState, action); + + expect(nextState.loading).to.be.true; + }); + }); + describe('RESERVATIONS_RATE_REPORT_GET_SUCCESS', () => { + const success = createAction(actionTypes.RESERVATIONS_RATE_REPORT_GET_SUCCESS); + + it('sets correct loading status and error message', () => { + const action = success(); + const currentState = { + loading: true, + errorMessage: 'Error X', + }; + const nextState = reservationsRateReportModalReducer(currentState, action); + expect(nextState).to.eql({ + loading: false, + errorMessage: '', + }); + }); + }); + describe('RESERVATIONS_RATE_REPORT_GET_ERROR', () => { + const error = createAction(actionTypes.RESERVATIONS_RATE_REPORT_GET_ERROR); + const payload = { + response: ['Error X'], + }; + it('sets correct loading status and error message', () => { + const action = error(payload); + const currentState = { + loading: true, + errorMessage: '', + }; + const nextState = reservationsRateReportModalReducer(currentState, action); + expect(nextState).to.eql({ + loading: false, + errorMessage: 'Error X', + }); + }); + }); + describe('RESERVATIONS_RATE_REPORT_GET_ERROR', () => { + const change = createAction('CHANGE_RESERVATIONS_RATE_REPORT_MODAL_FILTERS'); + const payload = { + unitSelections: [{ + label: '1', value: 1, + }], + }; + it('sets correct filters', () => { + const action = change(payload); + const nextState = reservationsRateReportModalReducer({}, action); + expect(nextState.filters).to.eql(payload); + }); + }); + describe('SHOW_RESERVATIONS_RATE_REPORT_MODAL', () => { + const show = createAction('SHOW_RESERVATIONS_RATE_REPORT_MODAL'); + it('sets show to true', () => { + const nextState = reservationsRateReportModalReducer({}, show()); + expect(nextState.show).to.be.true; + }); + }); + describe('HIDE_RESERVATIONS_RATE_REPORT_MODAL', () => { + const show = createAction('HIDE_RESERVATIONS_RATE_REPORT_MODAL'); + it('sets show to false', () => { + const nextState = reservationsRateReportModalReducer({}, show()); + expect(nextState.show).to.be.false; + }); + }); + }); +}); diff --git a/config/webpack.tests.js b/config/webpack.tests.js index 4f527efb..f74993e8 100644 --- a/config/webpack.tests.js +++ b/config/webpack.tests.js @@ -35,6 +35,10 @@ module.exports = merge(common, { presets: ['es2015', 'node6', 'react', 'stage-2'], }, }, + { + test: /\.css$/, + loader: 'style-loader!css-loader', + }, ], }, plugins: [ diff --git a/package.json b/package.json index 889b4289..55d81d56 100644 --- a/package.json +++ b/package.json @@ -44,16 +44,17 @@ "moment-range": "2.2.0", "moment-timezone": "0.5.13", "morgan": "1.9.0", - "node-sass": "4.5.2", + "node-sass": "4.14.1", "normalizr": "2.2.1", "passport": "0.3.2", - "passport-helsinki": "git://github.com/City-of-Helsinki/passport-helsinki.git", + "passport-helsinki": "git+https://github.com/City-of-Helsinki/passport-helsinki.git", "postcss-loader": "1.1.1", "query-string": "4.2.3", "react": "15.3.2", "react-addons-css-transition-group": "15.3.2", "react-bootstrap": "0.30.6", - "react-date-picker": "git://github.com/YoYuUm/react-date-picker.git#build-readonly", + "react-bootstrap-typeahead": "3.4.7", + "react-date-picker": "git+https://github.com/YoYuUm/react-date-picker.git#build-readonly", "react-document-title": "2.0.2", "react-dom": "15.3.2", "react-fontawesome": "1.5.0",