Skip to content
This repository has been archived by the owner on Oct 11, 2023. It is now read-only.

WIP: Timezone preferences #147

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/actions/timePreferencesActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const SET_DISPLAY_TIMEZONES = 'SET_DISPLAY_TIMEZONES'

export const setDisplayTimezones = (timezoneIanaNames) => ({
type: SET_DISPLAY_TIMEZONES,
timezoneIanaNames
})
10 changes: 7 additions & 3 deletions src/components/Timeline/Event/EventCard.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,7 +20,7 @@ export const EventCard = ({
>
<CardHeader
title={titleText}
subtitle={timeFormattedToMultipleZones(DateTime.fromISO(event.occurred ? event.occurred : event.Occurred))}
subtitle={getTimeDisplay(event)}
iconStyle={{
color: 'black'
}}
Expand All @@ -43,6 +42,11 @@ EventCard.propTypes = {
IconType: PropTypes.func
}

export const getTimeDisplay = (event) =>
event.occurred || event.Occurred
? <TimeDisplay time={event.occurred || event.Occurred} />
: null

export const mapStateToEventCardProps = (state, ownProps) => {
const { event, ticketId } = ownProps
const eventType = state.eventTypes.records[event.eventTypeId]
Expand Down
31 changes: 29 additions & 2 deletions src/components/TopNav/Preferences.js
Original file line number Diff line number Diff line change
@@ -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
}) => <div style={{ padding: '16px' }}>
<h3 key='signalREventFilter'>Event Filter Preferences:</h3>
<RadioButtonGroup
Expand All @@ -26,11 +31,33 @@ export const EventFilterPreferences = ({
)
}
</RadioButtonGroup>

<h3 key='timePreferences'>Time Preferences:</h3>
Display times in timezone:
<Menu multiple onChange={onTimezoneClick(dispatch)} value={selectedTimezones}>
{zones.map(zone => (
<MenuItem
key={zone.ianaZone}
checked={isTimezoneChecked(zone.ianaZone, selectedTimezones)}
primaryText={zone.shortname}
value={zone.ianaZone}
/>
))}
</Menu>
</div>

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)
Expand Down
8 changes: 2 additions & 6 deletions src/components/elements/ErrorMessage.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
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,
actionForRetry,
time = null,
backgroundColor = null
}) => {
const errorMessageTime = time && time instanceof DateTime ? time.toLocal().toFormat(DateTime.TIME_WITH_SECONDS) : null

return <Card
className='incident-card'
style={{ backgroundColor }}
>
<ErrorIcon />
<CardHeader
title={message}
subtitle={timeFormattedToMultipleZones(errorMessageTime)}
subtitle={<TimeDisplay time={time} />}
/>
{ actionForRetry ? <RetryButton actionForRetry={actionForRetry} /> : null }
</Card>
Expand Down
39 changes: 39 additions & 0 deletions src/components/elements/TimeDisplay.js
Original file line number Diff line number Diff line change
@@ -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 (
<div>
{timezones
.map(timezone => (
<div key={timezone}>({timezone}): {convertedTime(time, timezone)}</div>
))
}
</div>
)
}

// 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)
37 changes: 3 additions & 34 deletions src/helpers/timeFormattedToMultipleZones.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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)
4 changes: 3 additions & 1 deletion src/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +20,8 @@ const rootReducer = (filters, defaultEventFilterPreference) => combineReducers({
signalR: signalR(defaultEventFilterPreference),
eventTypes,
globalActions,
notifications
notifications,
timePreferences
})

export default rootReducer
30 changes: 30 additions & 0 deletions src/reducers/timePreferencesReducer.js
Original file line number Diff line number Diff line change
@@ -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})
)
3 changes: 3 additions & 0 deletions test/components/TopNav/PreferencesTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ describe('Preferences', function () {
filterPreferences: {
eventFilterType: expectedFilterType
}
},
timePreferences: {
timePreference: ['UTC']
}
}

Expand Down
83 changes: 35 additions & 48 deletions test/components/elements/ErrorMessageTest.js
Original file line number Diff line number Diff line change
@@ -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(<ErrorMessage message={'TestMessage'} />)

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(<ErrorMessage message={'TestMessage'} actionForRetry={'TestAction'} />)

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(<ErrorMessage time={testTime} />)

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(<ErrorMessage backgroundColor={backgroundColor} />)
describeSnapshot(function () {
it('should match snapshot', function () {
expect(toJson(testObject)).to.matchSnapshot()
})
})
})
})

})
Loading