Skip to content

Commit

Permalink
fix: confirmation before navigating away from unsaved state (DHIS2-84…
Browse files Browse the repository at this point in the history
…32) (#788)

* Displays a browser beforeunload confirmation message when trying to go to a different URL, close the window/tab, go back to another URL etc.

* Displays a custom ui-core Modal when clicking 'Open' or 'New'
  • Loading branch information
martinkrulltott authored Mar 10, 2020
1 parent 7b60e3c commit 66e1dbc
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 37 deletions.
29 changes: 23 additions & 6 deletions packages/app/i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,32 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"POT-Creation-Date: 2020-02-28T11:09:36.828Z\n"
"PO-Revision-Date: 2020-02-28T11:09:36.828Z\n"
"POT-Creation-Date: 2020-03-10T12:41:49.302Z\n"
"PO-Revision-Date: 2020-03-10T12:41:49.302Z\n"

msgid "Rename successful"
msgstr ""

msgid "\"{{what}}\" successfully deleted."
msgstr ""

msgid "You have unsaved changes."
msgstr ""

msgid "Discard unsaved changes?"
msgstr ""

msgid ""
"Are you sure you want to leave this visualization? Any unsaved changes will "
"be lost."
msgstr ""

msgid "No, cancel"
msgstr ""

msgid "Yes, leave"
msgstr ""

msgid "Axis 1"
msgstr ""

Expand Down Expand Up @@ -160,7 +177,7 @@ msgstr ""
msgid "Add dimensions to the layout above"
msgstr ""

msgid "Double click a dimension to add or remove items"
msgid "Click a dimension to add or remove items"
msgstr ""

msgid "Your most viewed charts and tables"
Expand Down Expand Up @@ -506,13 +523,13 @@ msgstr ""
msgid "Add at least one item to {{axisName}}."
msgstr ""

msgid "No period set"
msgid "No period selected"
msgstr ""

msgid "{{visualizationType}} must have at least one period set in {{axes}}."
msgid "{{visualizationType}} must have at least one period selected in {{axes}}."
msgstr ""

msgid "No data set"
msgid "No data selected"
msgstr ""

msgid ""
Expand Down
77 changes: 75 additions & 2 deletions packages/app/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import React, { Component } from 'react'
import { connect } from 'react-redux'
import PropTypes from 'prop-types'
import i18n from '@dhis2/d2-i18n'
import { CssVariables } from '@dhis2/ui-core'
import {
CssVariables,
Modal,
ModalTitle,
ModalContent,
ModalActions,
ButtonStrip,
Button,
} from '@dhis2/ui-core'

import DndContext from './DndContext'
import Snackbar from '../components/Snackbar/Snackbar'
Expand All @@ -27,6 +35,8 @@ import './scrollbar.css'
import { getParentGraphMapFromVisualization } from '../modules/ui'
import AxisSetup from './AxisSetup/AxisSetup'
import { APPROVAL_LEVEL_OPTION_AUTH } from './VisualizationOptions/Options/ApprovalLevel'
import { sGetVisualization } from '../reducers/visualization'
import { STATE_DIRTY, getVisualizationState } from '../modules/visualization'

export class App extends Component {
unlisten = null
Expand All @@ -36,6 +46,7 @@ export class App extends Component {
state = {
previousLocation: null,
initialLoadIsComplete: false,
locationToConfirm: false,
}

/**
Expand Down Expand Up @@ -142,7 +153,18 @@ export class App extends Component {
this.loadVisualization(this.props.location)

this.unlisten = history.listen(location => {
this.loadVisualization(location)
if (
getVisualizationState(
this.props.visualization,
this.props.current
) === STATE_DIRTY &&
this.state.locationToConfirm !== location
) {
this.setState({ locationToConfirm: location })
} else {
this.setState({ locationToConfirm: null })
this.loadVisualization(location)
}
})

document.body.addEventListener(
Expand All @@ -152,6 +174,18 @@ export class App extends Component {
e.ctrlKey === true &&
this.props.setCurrentFromUi(this.props.ui)
)

window.addEventListener('beforeunload', event => {
if (
getVisualizationState(
this.props.visualization,
this.props.current
) === STATE_DIRTY
) {
event.preventDefault()
event.returnValue = i18n.t('You have unsaved changes.')
}
})
}

componentWillUnmount() {
Expand Down Expand Up @@ -210,6 +244,43 @@ export class App extends Component {
)}
</div>
</div>
{this.state.locationToConfirm && (
<Modal small>
<ModalTitle>
{i18n.t('Discard unsaved changes?')}
</ModalTitle>
<ModalContent>
{i18n.t(
'Are you sure you want to leave this visualization? Any unsaved changes will be lost.'
)}
</ModalContent>
<ModalActions>
<ButtonStrip end>
<Button
secondary
onClick={() =>
this.setState({
locationToConfirm: null,
})
}
>
{i18n.t('No, cancel')}
</Button>

<Button
onClick={() =>
history.push(
this.state.locationToConfirm
)
}
primary
>
{i18n.t('Yes, leave')}
</Button>
</ButtonStrip>
</ModalActions>
</Modal>
)}
<Snackbar />
<CssVariables colors spacers />
</>
Expand All @@ -222,6 +293,7 @@ const mapStateToProps = state => ({
current: fromReducers.fromCurrent.sGetCurrent(state),
interpretations: fromReducers.fromVisualization.sGetInterpretations(state),
ui: fromReducers.fromUi.sGetUi(state),
visualization: sGetVisualization(state),
})

const mapDispatchToProps = dispatch => ({
Expand Down Expand Up @@ -259,6 +331,7 @@ App.propTypes = {
settings: PropTypes.object,
ui: PropTypes.object,
userSettings: PropTypes.object,
visualization: PropTypes.object,
}

export default connect(mapStateToProps, mapDispatchToProps)(App)
32 changes: 9 additions & 23 deletions packages/app/src/components/TitleBar/TitleBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,23 @@ import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import i18n from '@dhis2/d2-i18n'

import {
sGetVisualization,
DEFAULT_VISUALIZATION,
} from '../../reducers/visualization'
import { sGetCurrent, DEFAULT_CURRENT } from '../../reducers/current'
import { sGetVisualization } from '../../reducers/visualization'
import { sGetCurrent } from '../../reducers/current'
import { sGetUiInterpretation } from '../../reducers/ui'
import { sGetUiLocale } from '../../reducers/settings'
import formatDate from '../../modules/formatDate'
import InterpretationIcon from '../../assets/InterpretationIcon'
import styles from './styles/TitleBar.style'
import {
STATE_UNSAVED,
STATE_SAVED,
STATE_DIRTY,
getVisualizationState,
} from '../../modules/visualization'

export const TITLE_UNSAVED = i18n.t('Unsaved visualization')
export const TITLE_DIRTY = i18n.t('Edited')

export const STATE_EMPTY = 'EMPTY'
export const STATE_SAVED = 'SAVED'
export const STATE_UNSAVED = 'UNSAVED'
export const STATE_DIRTY = 'DIRTY'

const defaultTitleStyle = {
...styles.cell,
...styles.title,
Expand All @@ -32,18 +30,6 @@ const defaultInterpretationStyle = {
...styles.interpretation,
}

const getTitleState = (visualization, current) => {
if (current === DEFAULT_CURRENT) {
return STATE_EMPTY
} else if (visualization === DEFAULT_VISUALIZATION) {
return STATE_UNSAVED
} else if (current === visualization) {
return STATE_SAVED
} else {
return STATE_DIRTY
}
}

const getTitleText = (titleState, visualization) => {
switch (titleState) {
case STATE_UNSAVED:
Expand Down Expand Up @@ -127,7 +113,7 @@ const mapStateToProps = state => ({

const mergeProps = (stateProps, dispatchProps, ownProps) => {
const { visualization, current, interpretation, uiLocale } = stateProps
const titleState = getTitleState(visualization, current)
const titleState = getVisualizationState(visualization, current)
return {
...dispatchProps,
...ownProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import React from 'react'
import { shallow } from 'enzyme'
import { TitleBar, TITLE_UNSAVED, TITLE_DIRTY } from '../TitleBar'
import {
TitleBar,
STATE_EMPTY,
STATE_SAVED,
STATE_UNSAVED,
STATE_SAVED,
STATE_DIRTY,
TITLE_UNSAVED,
TITLE_DIRTY,
} from '../TitleBar'
} from '../../../modules/visualization'

describe('TitleBar component', () => {
let props
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/components/__tests__/App.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getStubContext } from '../../../../../config/testsContext'
import { CURRENT_AO_KEY } from '../../api/userDataStore'
import * as userDataStore from '../../api/userDataStore'
import * as ui from '../../modules/ui'
import { DEFAULT_CURRENT } from '../../reducers/current'

jest.mock('../Visualization/Visualization', () => () => <div />)

Expand Down Expand Up @@ -40,7 +41,7 @@ describe('App', () => {
snackbarMessage: '',
loadError: null,
interpretations: [],
current: {},
current: DEFAULT_CURRENT,
ui: { rightSidebarOpen: false },
location: { pathname: '/' },
settings: {
Expand Down
19 changes: 19 additions & 0 deletions packages/app/src/modules/visualization.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import options from './options'
import { DEFAULT_VISUALIZATION } from '../reducers/visualization'
import { DEFAULT_CURRENT } from '../reducers/current'

export const getVisualizationFromCurrent = current => {
const nonSavableOptions = Object.keys(options).filter(
Expand All @@ -9,3 +11,20 @@ export const getVisualizationFromCurrent = current => {

return current
}

export const getVisualizationState = (visualization, current) => {
if (current === DEFAULT_CURRENT) {
return STATE_EMPTY
} else if (visualization === DEFAULT_VISUALIZATION) {
return STATE_UNSAVED
} else if (current === visualization) {
return STATE_SAVED
} else {
return STATE_DIRTY
}
}

export const STATE_EMPTY = 'EMPTY'
export const STATE_SAVED = 'SAVED'
export const STATE_UNSAVED = 'UNSAVED'
export const STATE_DIRTY = 'DIRTY'

0 comments on commit 66e1dbc

Please sign in to comment.