The article Confirming Navigation explains how to use a browser confirmation box in your transition hook. Fine. But I want to use my own Dialog box. If I were to use the methods
I made it work by setting a boolean on state whether you have confirmed to navigate away (using react-router 2.8.x). As it says in the link you posted: https://github.com/ReactTraining/react-router/blob/master/docs/guides/ConfirmingNavigation.md
return false to prevent a transition w/o prompting the user
However, they forget to mention that the hook should be unregistered as well, see here and here.
We can use this to implement our own solution as follows:
class YourComponent extends Component {
constructor() {
super();
const {route} = this.props;
const {router} = this.context;
this.onCancel = this.onCancel.bind(this);
this.onConfirm = this.onConfirm.bind(this);
this.unregisterLeaveHook = router.setRouteLeaveHook(
route,
this.routerWillLeave.bind(this)
);
}
componentWillUnmount() {
this.unregisterLeaveHook();
}
routerWillLeave() {
const {hasConfirmed} = this.state;
if (!hasConfirmed) {
this.setState({showConfirmModal: true});
// Cancel route change
return false;
}
// User has confirmed. Navigate away
return true;
}
onCancel() {
this.setState({showConfirmModal: false});
}
onConfirm() {
this.setState({hasConfirmed: true, showConfirmModal: true}, function () {
this.context.router.goBack();
}.bind(this));
}
render() {
const {showConfirmModal} = this.state;
return (
<ConfirmModal
isOpen={showConfirmModal}
onCancel={this.onCancel}
onConfirm={this.onConfirm} />
);
}
}
YourComponent.contextTypes = {
router: routerShape
};
Here's my solution to the same. I made a custom dialog component that you can use to wrap any component in your app. You can wrap your header and this way have it present on all pages. It assumes you're using Redux Form, but you can simply replace areThereUnsavedChanges
with some other form change checking code. It also uses React Bootstrap modal, which again you can replace with your own custom dialog.
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { withRouter, browserHistory } from 'react-router'
import { translate } from 'react-i18next'
import { Button, Modal, Row, Col } from 'react-bootstrap'
// have to use this global var, because setState does things at unpredictable times and dialog gets presented twice
let navConfirmed = false
@withRouter
@connect(
state => ({ form: state.form })
)
export default class UnsavedFormModal extends Component {
constructor(props) {
super(props)
this.areThereUnsavedChanges = this.areThereUnsavedChanges.bind(this)
this.state = ({ unsavedFormDialog: false })
}
areThereUnsavedChanges() {
return this.props.form && Object.values(this.props.form).length > 0 &&
Object.values(this.props.form)
.findIndex(frm => (Object.values(frm)
.findIndex(field => field && field.initial && field.initial !== field.value) !== -1)) !== -1
}
render() {
const moveForward = () => {
this.setState({ unsavedFormDialog: false })
navConfirmed = true
browserHistory.push(this.state.nextLocation.pathname)
}
const onHide = () => this.setState({ unsavedFormDialog: false })
if (this.areThereUnsavedChanges() && this.props.router && this.props.routes && this.props.routes.length > 0) {
this.props.router.setRouteLeaveHook(this.props.routes[this.props.routes.length - 1], (nextLocation) => {
if (navConfirmed || !this.areThereUnsavedChanges()) {
navConfirmed = false
return true
} else {
this.setState({ unsavedFormDialog: true, nextLocation: nextLocation })
return false
}
})
}
return (
<div>
{this.props.children}
<Modal show={this.state.unsavedFormDialog} onHide={onHide} bsSize="sm" aria-labelledby="contained-modal-title-md">
<Modal.Header>
<Modal.Title id="contained-modal-title-md">WARNING: unsaved changes</Modal.Title>
</Modal.Header>
<Modal.Body>
Are you sure you want to leave the page without saving changes to the form?
<Row>
<Col xs={6}><Button block onClick={onHide}>Cancel</Button></Col>
<Col xs={6}><Button block onClick={moveForward}>OK</Button></Col>
</Row>
</Modal.Body>
</Modal>
</div>
)
}
}
The core problem is that setRouteLeaveHook
expects the hook function to return its result synchronously. This means you don't have the time to display a custom dialog component, wait for the user to click an option, and then return the result. So we need a way to specify an asynchronous hook. Here's a utility function I wrote:
// Asynchronous version of `setRouteLeaveHook`.
// Instead of synchronously returning a result, the hook is expected to
// return a promise.
function setAsyncRouteLeaveHook(router, route, hook) {
let withinHook = false
let finalResult = undefined
let finalResultSet = false
router.setRouteLeaveHook(route, nextLocation => {
withinHook = true
if (!finalResultSet) {
hook(nextLocation).then(result => {
finalResult = result
finalResultSet = true
if (!withinHook && nextLocation) {
// Re-schedule the navigation
router.push(nextLocation)
}
})
}
let result = finalResultSet ? finalResult : false
withinHook = false
finalResult = undefined
finalResultSet = false
return result
})
}
Here is an example of how to use it, using vex to show a dialog box:
componentWillMount() {
setAsyncRouteLeaveHook(this.context.router, this.props.route, this.routerWillLeave)
}
routerWillLeave() {
return new Promise((resolve, reject) => {
if (!this.state.textValue) {
// No unsaved changes -- leave
resolve(true)
} else {
// Unsaved changes -- ask for confirmation
vex.dialog.confirm({
message: 'There are unsaved changes. Leave anyway?' + nextLocation,
callback: result => resolve(result)
})
}
})
}
Above is great except when user goes back in history. Something like the following should fix the problem:
if (!withinHook && nextLocation) {
if (nextLocation.action=='POP') {
router.goBack()
} else {
router.push(nextLocation)
}
}
Posting my solution for intercept back button or even a route change. This works with React-router 2.8 or higher. Or even with withRouter
import React, {PropTypes as T} from 'react';
...
componentWillMount() {
this.context.router.setRouteLeaveHook(this.props.route, this.routerWillLeaveCallback.bind(this));
}
routerWillLeaveCallback(nextLocation) {
let showModal = this.state.unsavedChanges;
if (showModal) {
this.setState({
openUnsavedDialog: true,
unsavedResolveCallback: Promise.resolve
});
return false;
}
return true;
}
}
YourComponent.contextTypes = {
router: T.object.isRequired
};