diff --git a/src/actions/timePreferencesActions.js b/src/actions/timePreferencesActions.js new file mode 100644 index 0000000..d054b55 --- /dev/null +++ b/src/actions/timePreferencesActions.js @@ -0,0 +1,6 @@ +export const SET_DISPLAY_TIMEZONES = 'SET_DISPLAY_TIMEZONES' + +export const setDisplayTimezones = (timezoneIanaNames) => ({ + type: SET_DISPLAY_TIMEZONES, + timezoneIanaNames +}) diff --git a/src/components/Timeline/Event/EventCard.js b/src/components/Timeline/Event/EventCard.js index ae2c27f..ebf8bfc 100644 --- a/src/components/Timeline/Event/EventCard.js +++ b/src/components/Timeline/Event/EventCard.js @@ -1,14 +1,13 @@ import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { DateTime } from 'luxon' import { Card, CardHeader, CardActions } from 'material-ui/Card' import * as Icons from 'material-ui/svg-icons' import Avatar from 'material-ui/Avatar' import Playbook from 'components/Timeline/Playbook/Playbook' import { LoadTextFromEvent } from 'services/playbookService' -import timeFormattedToMultipleZones from 'helpers/timeFormattedToMultipleZones' +import TimeDisplay from 'components/elements/TimeDisplay' export const EventCard = ({ event, @@ -21,7 +20,7 @@ export const EventCard = ({ > + event.occurred || event.Occurred + ? + : null + export const mapStateToEventCardProps = (state, ownProps) => { const { event, ticketId } = ownProps const eventType = state.eventTypes.records[event.eventTypeId] diff --git a/src/components/TopNav/Preferences.js b/src/components/TopNav/Preferences.js index 1da10d9..dc0f30f 100644 --- a/src/components/TopNav/Preferences.js +++ b/src/components/TopNav/Preferences.js @@ -1,13 +1,18 @@ import React from 'react' import { connect } from 'react-redux' import { RadioButton, RadioButtonGroup } from 'material-ui/RadioButton' +import Menu from 'material-ui/Menu' +import MenuItem from 'material-ui/MenuItem' +import { zones } from 'helpers/timeFormattedToMultipleZones' import * as signalRActions from 'actions/signalRActions' +import * as timePreferencesActions from 'actions/timePreferencesActions' export const EventFilterPreferences = ({ currentEventFilterObject, currentEventFilterPreference, - dispatch + dispatch, + selectedTimezones }) =>

Event Filter Preferences:

+ +

Time Preferences:

+ Display times in timezone: + + {zones.map(zone => ( + + ))} +
+const onTimezoneClick = (dispatch) => (event, value) => + dispatch(timePreferencesActions.setDisplayTimezones(value)) + +const isTimezoneChecked = (ianaZoneName, selectedTimezones) => + selectedTimezones && + selectedTimezones.includes && + selectedTimezones.includes(ianaZoneName) + export const mapStateToEventFilterPreferencesProps = (state) => ({ currentEventFilterObject: state.events.filter, - currentEventFilterPreference: state.signalR.filterPreferences.eventFilterType + currentEventFilterPreference: state.signalR.filterPreferences.eventFilterType, + selectedTimezones: state.timePreferences.displayTimezones }) export const ConnectedEventFilterPreferences = connect(mapStateToEventFilterPreferencesProps)(EventFilterPreferences) diff --git a/src/components/elements/ErrorMessage.js b/src/components/elements/ErrorMessage.js index be5fd2c..c00036d 100644 --- a/src/components/elements/ErrorMessage.js +++ b/src/components/elements/ErrorMessage.js @@ -1,10 +1,8 @@ import React from 'react' -import { DateTime } from 'luxon' import ErrorIcon from 'material-ui/svg-icons/alert/error' import { Card, CardHeader } from 'material-ui/Card' import { RetryButton } from 'components/elements/Buttons' - -import timeFormattedToMultipleZones from 'helpers/timeFormattedToMultipleZones' +import TimeDisplay from 'components/elements/TimeDisplay' export const ErrorMessage = ({ message, @@ -12,8 +10,6 @@ export const ErrorMessage = ({ time = null, backgroundColor = null }) => { - const errorMessageTime = time && time instanceof DateTime ? time.toLocal().toFormat(DateTime.TIME_WITH_SECONDS) : null - return } /> { actionForRetry ? : null } diff --git a/src/components/elements/TimeDisplay.js b/src/components/elements/TimeDisplay.js new file mode 100644 index 0000000..3596b66 --- /dev/null +++ b/src/components/elements/TimeDisplay.js @@ -0,0 +1,39 @@ +import { connect } from 'react-redux' +import React from 'react' +import { DateTime } from 'luxon' + +import { timeAndDateFormat } from 'helpers/timeFormattedToMultipleZones' + +export const TimeDisplayComponent = ({time, timezones}) => { + if (!time) { + return null + } + + return ( +
+ {timezones + .map(timezone => ( +
({timezone}): {convertedTime(time, timezone)}
+ )) + } +
+ ) +} + +// TODO: Test convertedTime, especially w/ invalid DateTime values +const convertedTime = (time, timezone) => + parseTime(time) + .setZone(timezone) + .toLocaleString(timeAndDateFormat) + +export const parseTime = (time) => + time instanceof DateTime + ? time + : DateTime.fromISO(time) + +export const mapStateToProps = (state, ownProps) => ({ + timezones: state.timePreferences.displayTimezones, + time: ownProps.time +}) + +export default connect(mapStateToProps)(TimeDisplayComponent) diff --git a/src/helpers/timeFormattedToMultipleZones.js b/src/helpers/timeFormattedToMultipleZones.js index efd1b5f..1856ffd 100644 --- a/src/helpers/timeFormattedToMultipleZones.js +++ b/src/helpers/timeFormattedToMultipleZones.js @@ -1,12 +1,11 @@ import { DateTime } from 'luxon' -import _ from 'underscore' // PST https://en.wikipedia.org/wiki/List_of_tz_database_time_zones // India's IANA zone https://en.wikipedia.org/wiki/Time_in_India -const zones = [ +export const zones = [ { shortname: 'PT', ianaZone: 'America/Los_Angeles' }, { shortname: 'IST', ianaZone: 'Asia/Kolkata' }, - { shortname: 'UTC', ianaZone: 'Etc/GMT' } + { shortname: 'UTC', ianaZone: 'UTC' } ] const dateFormat = { @@ -15,34 +14,4 @@ const dateFormat = { day: '2-digit' } -const timeAndDateformat = Object.assign(dateFormat, DateTime.TIME_24_WITH_SECONDS) - -export const timeFormattedToMultipleZones = (time, timezones = zones) => { - if (!time) { return '' } - - let convertTimeToZone = (timepoint) => { - timepoint.timeInZone = time.setZone(timepoint.ianaZone) - return timepoint - } - - let timeAndDate = (timepoint, index) => { - let format = index === 0 ? timeAndDateformat : DateTime.TIME_24_WITH_SECONDS - - return timepoint.timeInZone.toLocaleString(format) + ' ' + timepoint.shortname - } - - let transformToTimeAndDateString = (dateGroup) => _.map(dateGroup, timeAndDate).join(', ') - let dateForTimepoint = (timepoint) => timepoint.timeInZone.toLocaleString(DateTime.DATE_SHORT) - - let timeInMultipleZones = _.chain(timezones) - .map(convertTimeToZone) - .groupBy(dateForTimepoint) - .map(transformToTimeAndDateString) - .compact() - .value() - .join('; ') - - return timeInMultipleZones -} - -export default timeFormattedToMultipleZones +export const timeAndDateFormat = Object.assign(dateFormat, DateTime.TIME_24_WITH_SECONDS) diff --git a/src/reducers/index.js b/src/reducers/index.js index a500d14..a23ea78 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -9,6 +9,7 @@ import forms from 'reducers/formReducer' import eventTypes from 'reducers/eventTypeReducer' import globalActions from 'reducers/globalActionReducer' import notifications from 'reducers/notificationReducer' +import timePreferences from 'reducers/timePreferencesReducer' const rootReducer = (filters, defaultEventFilterPreference) => combineReducers({ incidents, @@ -19,7 +20,8 @@ const rootReducer = (filters, defaultEventFilterPreference) => combineReducers({ signalR: signalR(defaultEventFilterPreference), eventTypes, globalActions, - notifications + notifications, + timePreferences }) export default rootReducer diff --git a/src/reducers/timePreferencesReducer.js b/src/reducers/timePreferencesReducer.js new file mode 100644 index 0000000..6f804e2 --- /dev/null +++ b/src/reducers/timePreferencesReducer.js @@ -0,0 +1,30 @@ +import { combineReducers } from 'redux' +import { persistReducer } from 'redux-persist' +import storage from 'redux-persist/lib/storage' // default: localStorage if web, AsyncStorage if react-native + +import * as timePreferencesActions from 'actions/timePreferencesActions' + +const defaultDisplayTimezones = ['UTC'] + +export const displayTimezonesReducer = (state = defaultDisplayTimezones, action) => { + switch (action.type) { + case timePreferencesActions.SET_DISPLAY_TIMEZONES: + // Require that one timezone is always configured. Force the default + // timezone if the user deselects all timezones. + if (action.timezoneIanaNames.length <= 0) { + return defaultDisplayTimezones + } + return action.timezoneIanaNames + default: + return state + } +} + +const persistConfigs = { + key: 'timePreferences', + storage +} +export default persistReducer( + persistConfigs, + combineReducers({displayTimezones: displayTimezonesReducer}) +) diff --git a/test/components/TopNav/PreferencesTest.js b/test/components/TopNav/PreferencesTest.js index 79c59dc..b347718 100644 --- a/test/components/TopNav/PreferencesTest.js +++ b/test/components/TopNav/PreferencesTest.js @@ -61,6 +61,9 @@ describe('Preferences', function () { filterPreferences: { eventFilterType: expectedFilterType } + }, + timePreferences: { + timePreference: ['UTC'] } } diff --git a/test/components/elements/ErrorMessageTest.js b/test/components/elements/ErrorMessageTest.js index 8a8b68e..d735f50 100644 --- a/test/components/elements/ErrorMessageTest.js +++ b/test/components/elements/ErrorMessageTest.js @@ -1,77 +1,64 @@ 'use strict' +import React from 'react' import { expect } from 'chai' import { DateTime } from 'luxon' - -import createComponent from 'test/helpers/shallowRenderHelper' +import { shallow } from 'enzyme' +import 'test/helpers/configureEnzyme' +import toJson from 'enzyme-to-json' +import { describeSnapshot } from 'test/helpers/describeSnapshot' import ErrorMessage from 'components/elements/ErrorMessage' -import { Card, CardHeader } from 'material-ui/Card' -import ErrorIcon from 'material-ui/svg-icons/alert/error' -import { RetryButton } from 'components/elements/Buttons' describe('ErrorMessage', function () { context('when inputs are valid', function () { describe('when given only a message', function () { - const testObject = createComponent(ErrorMessage, {message: 'TestMessage'}) - - it('Should render a Card with an ErrorIcon icon and a CardHeader with the given message', function () { - expect(testObject.type).to.equal(Card) - expect(testObject.props.children[0].type).to.equal(ErrorIcon) - expect(testObject.props.children[1].type).to.equal(CardHeader) - expect(testObject.props.children[1].props.title).to.equal('TestMessage') - }) + const testObject = shallow() - it('should render a card with a CardHeader with an empty subtitle', function () { - expect(testObject.props.children[1].props.subtitle).to.be.empty + describeSnapshot(function () { + it('should match snapshot', function () { + expect(toJson(testObject)).to.matchSnapshot() + }) }) }) describe('when given a message and provided an action for retry', function () { - const testObject = createComponent(ErrorMessage, {message: 'TestMessage', actionForRetry: 'TestAction'}) + const testObject = shallow() - it('Should additionally render a retry button', function () { - expect(testObject.props.children[2].type).to.equal(RetryButton) - expect(testObject.props.children[2].props.actionForRetry).to.equal('TestAction') + describeSnapshot(function () { + it('should match snapshot', function () { + expect(toJson(testObject)).to.matchSnapshot() + }) }) }) describe('when given a time', function () { - const testTime = DateTime.utc() - const testObject = createComponent(ErrorMessage, {time: testTime}) - - it('should render a card with a CardHeader with the given time as a subtitle', function () { - expect(testObject.props.children[1].props.subtitle).to.equal(testTime.toLocal().toFormat(DateTime.TIME_WITH_SECONDS)) - }) - }) - - describe('when given a background color', function () { - const backgroundColor = 'Purple' - const testObject = createComponent(ErrorMessage, {backgroundColor}) - - it('should render a card styled with that background color', function () { - expect(testObject.props.style).to.deep.equal({ backgroundColor: 'Purple' }) + const testTime = DateTime.fromObject({ + year: 2018, + month: 4, + day: 25, + hour: 15, + minute: 39, + second: 22, + zone: 'America/Los_Angeles' }) - }) - }) - context('when inputs are not valid', function () { - describe('when time is undefined', function () { - const testTime = undefined - const testObject = createComponent(ErrorMessage, {time: testTime}) + const testObject = shallow() - it('should return an empty subtitle', function () { - expect(testObject.props.children[1].props.subtitle).to.be.empty + describeSnapshot(function () { + it('should match snapshot', function () { + expect(toJson(testObject)).to.matchSnapshot() + }) }) }) - describe('when time is null', function () { - const testTime = undefined - const testObject = createComponent(ErrorMessage, {time: testTime}) - - it('should return an empty subtitle', function () { - expect(testObject.props.children[1].props.subtitle).to.be.empty + describe('when given a background color', function () { + const backgroundColor = 'Purple' + const testObject = shallow() + describeSnapshot(function () { + it('should match snapshot', function () { + expect(toJson(testObject)).to.matchSnapshot() + }) }) }) }) - }) diff --git a/test/components/elements/ErrorMessageTest.js.snap b/test/components/elements/ErrorMessageTest.js.snap new file mode 100644 index 0000000..13d42cf --- /dev/null +++ b/test/components/elements/ErrorMessageTest.js.snap @@ -0,0 +1,102 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ErrorMessage when inputs are valid when given a background color snapshot should match snapshot 1`] = ` + + + + } + /> + +`; + +exports[`ErrorMessage when inputs are valid when given a message and provided an action for retry snapshot should match snapshot 1`] = ` + + + + } + title="TestMessage" + /> + + +`; + +exports[`ErrorMessage when inputs are valid when given a time snapshot should match snapshot 1`] = ` + + + + } + /> + +`; + +exports[`ErrorMessage when inputs are valid when given only a message snapshot should match snapshot 1`] = ` + + + + } + title="TestMessage" + /> + +`; diff --git a/test/components/elements/TimeDisplayTest.js b/test/components/elements/TimeDisplayTest.js new file mode 100644 index 0000000..e52cbca --- /dev/null +++ b/test/components/elements/TimeDisplayTest.js @@ -0,0 +1,94 @@ +import React from 'react' +import { expect } from 'chai' +import { shallow } from 'enzyme' +import toJson from 'enzyme-to-json' +import { DateTime } from 'luxon' + + +import { describeSnapshot } from 'test/helpers/describeSnapshot' + +import { + TimeDisplayComponent, + parseTime, + mapStateToProps +} from 'components/elements/TimeDisplay' + +describe('TimeDisplay', function () { + const validTime = DateTime.fromObject({ + year: 2018, + month: 4, + day: 25, + hour: 15, + minute: 39, + second: 22, + zone: 'America/Los_Angeles' + }) + + describeSnapshot(function () { + it('should match snapshot', function () { + expect(toJson(shallow())) + .to.matchSnapshot() + }) + }) + + describe('when time is null', function () { + it('should return null', function () { + expect(toJson(shallow())) + .to.matchSnapshot() + }) + }) + + describe('parseTime', function () { + describe('when input is valid', function () { + it('should return value when given a DateTime', function () { + expect(parseTime(DateTime.utc(2018, 4, 25)).isValid).to.be.true + }) + + it('should return parsed ISO string when given ISO string', function () { + const validIsoTime = '2008-09-15T15:53:00+05:00' + + expect(parseTime(validIsoTime).isValid).to.be.true + }) + }) + + describe('when input is invalid', function () { + it('isValid should be false when given an integer', function () { + expect(parseTime(123).isValid).to.be.false + }) + + it('isValid should be false when given a non-ISO time string', function () { + expect(parseTime('not an ISO string').isValid).to.be.false + }) + }) + }) + + describe('mapStateToProps', function () { + const validStateObject = { + timePreferences: { + displayTimezones: ['UTC', 'America/Los_Angeles', 'other', 'timezones'] + } + } + const validOwnProps = { + time: 'time object' + } + + it('should return the correct timezones', function () { + const testState = mapStateToProps( + validStateObject, + validOwnProps + ) + + expect(testState.timezones).to.be.an('array') + .to.have.same.members(validStateObject.timePreferences.displayTimezones) + }) + + it('should get time from ownProps', function () { + const testState = mapStateToProps( + validStateObject, + validOwnProps + ) + + expect(testState.time).to.equal(validOwnProps.time) + }) + }) +}) diff --git a/test/components/elements/TimeDisplayTest.js.snap b/test/components/elements/TimeDisplayTest.js.snap new file mode 100644 index 0000000..1968656 --- /dev/null +++ b/test/components/elements/TimeDisplayTest.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimeDisplay snapshot should match snapshot 1`] = ` +
+
+ ( + UTC + ): + 04/25/2018, 22:39:22 +
+
+`; + +exports[`TimeDisplay snapshot should match snapshot 2`] = `""`; diff --git a/test/helpers/timeFormattedToMultipleZonesTest.js b/test/helpers/timeFormattedToMultipleZonesTest.js deleted file mode 100644 index 2cec621..0000000 --- a/test/helpers/timeFormattedToMultipleZonesTest.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict' -import { expect } from 'chai' -import { DateTime } from 'luxon'; -import timeFormattedToMultipleZones from 'helpers/timeFormattedToMultipleZones' - -describe('timeFormattedToMultipleZones', function test () { - let pacific = 'PT' - let india = 'IST' - let utc = 'UTC' - let yesterday = '1969-12-31' - let today = '1970-01-01' - let tomorrow = '1970-01-02' - - context('over yesterday and today', () => { - let time = DateTime.utc(1970, 1, 1, 0, 0) - - it('defaults to displaying time for Pacific, India, and UTC', () => { - expect(timeFormattedToMultipleZones(time)).to.contain(pacific, india, utc) - }) - - it('has the days the time occured in', () => { - expect(timeFormattedToMultipleZones(time)).to.contain(today, yesterday) - }) - - it('groups times by day', () => { - let expected = '1969-12-31 16:00:00 PT; 1970-01-01 05:30:00 IST, 00:00:00 UTC' - - expect(timeFormattedToMultipleZones(time)).to.eql(expected) - }) - }) - - context('In a single day', () => { - let time = DateTime.utc(1970, 1, 1, 0, 0).plus({ hours: 8 }) - - it('defaults to displaying time for Pacific, India, and UTC', () => { - expect(timeFormattedToMultipleZones(time)).to.contain(pacific, india, utc) - }) - - it('has only the ay the time occured in', () => { - expect(timeFormattedToMultipleZones(time)).to.contain(today) - expect(timeFormattedToMultipleZones(time)).to.not.contain(yesterday, tomorrow) - }) - - it('groups times by day', () => { - let expected = '1970-01-01 00:00:00 PT, 13:30:00 IST, 08:00:00 UTC' - - expect(timeFormattedToMultipleZones(time)).to.eql(expected) - }) - }) - - context('over today and tomorrow', () => { - let time = DateTime.utc(1970, 1, 1, 0, 0).plus({ hours: 18, minutes: 30 }) - - it('defaults to displaying time for Pacific, India, and UTC', () => { - expect(timeFormattedToMultipleZones(time)).to.contain(pacific, india, utc) - }) - - it('has the days the time occured in', () => { - expect(timeFormattedToMultipleZones(time)).to.contain(today, tomorrow) - }) - - it('groups times by day', () => { - let expected = '1970-01-01 10:30:00 PT, 18:30:00 UTC; 1970-01-02 00:00:00 IST' - - expect(timeFormattedToMultipleZones(time)).to.eql(expected) - }) - }) -})