I have a form where the user can edit stuff. If the user has some unsaved changed and wants to leave the page I want to give him a custom dialog, asking him if he is sure. If then the navigation should continue and the form changed flag should be reset, otherwise the user should stay at the page.
Here you can check my current higher order component which is checking this condition. It is working, but the function leave returns a string and react-router will then display that text in a native alert.
Is there a way that I can show my custom dialog in here? And can I also get a callback or similar of the alert, so that I can dispatch an event which tells the storage, that the latest changes are not important.
import React, {Component, PropTypes} from "react";
import connectRouter from "./connectRouter";
import { connect } from "react-redux";
import {injectIntl, intlShape} from "react-intl";
export default function confirmLeave(RouteTargetComponent) {
#injectIntl
#connectRouter
#connect((state, routing) => {
return {
studies: state.studies
};
})
class ConfirmLeaveHOC extends Component { // HOC = Higher Order Component
static propTypes = {
router: PropTypes.object.isRequired,
route: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: intlShape.isRequired,
studies: PropTypes.object.isRequired,
};
leave = () => {
if (this.props.studies.isChanged) {
// lets stop the navigation
return this.props.intl.formatMessage({ id: "confirmLeave" });
}
// continue the navigation
return true;
}
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, this.leave.bind(this));
}
render() {
// render the component that requires auth (passed to this wrapper)
return (<RouteTargetComponent {...this.props}/>);
}
}
return ConfirmLeaveHOC;
}
Since customizing browser dialogs is not possible, you'll have to render a separate component (e.g bootstrap modal) and use a callback to determine which button was clicked, and what action to take.
I actually ran into the same problem you're facing very recently, and I was able to solve it by using routerWillLeave and using callbacks from another component.
Form component
routerWillLeave = (route) => {
if (!this.waitingForConfirm && this._hasUnsavedChanges() && !this.clickedSave) {
this.refs.confirmAlert._show( ((v) => {
if (v) {
this.context.router.push(route.pathname);
}
this.waitingForConfirm = false;
}).bind(this));
this.waitingForConfirm = true;
return false;
}
}
The implementation of customized dialog like this one is unfortunately quite a pain in the back. I had to use 3 variables here to correctly control the desired behavior:
waitingForConfirm - necessary to prevent the logic from running a second time when the user confirms to navigate out. Specifically, when the callback is run and we do this.context.router.push(route.pathname), the routerWillLeave will run again(!), but since we've already confirmed navigation we must prevent this logic from running again.
_hasUnsavedChanges() - checks if any input fields have changed (no reason to ask if there's no changes to be saved).
clickedSave - don't ask for confirmation if the user clicked Save - we know we want to leave.
Dialog component
_show = (callback) => {
this.callback = callback;
this.setState({show: true});
}
_hide = () => {
this.setState({show: false});
this.callback = null;
}
_dialogAction = (input) => {
if (this.callback) {
this.callback(input);
}
this._hide();
}
render() {
return (
...
<Button onClick={this._dialogAction.bind(this, true)}>Yes</Button>
<Button onClick={this._dialogAction.bind(this, false)}>No</Button>
);
}
Obviously, you'll have to customize the above snippets to fit your application, but hopefully it will provide some insight into how to solve the problem.
A less complicated and more forward way is using a setRouteLeaveHook on your component. In accordance to the react-router v2.4.0
import React from 'react'
import { withRouter } from 'react-router'
const Page = React.createClass({
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, () => {
if (this.state.unsaved)
return 'You have unsaved information, are you sure you want to
leave this page?'
})
}
render() {
return Stuff
}
})
export default withRouter(Page)
Here is the React-router version on their github page
Related
If I've got a function that creates a confirm popup when you click the back button, I want to save the state before navigating back to the search page. The order is a bit odd, there's a search page, then a submit form page, and the summary page. I have replace set to true in the reach router so when I click back on the summary page it goes to the search page. I want to preserve the history and pass the state of the submitted data into history, so when I click forward it goes back to the page without error.
I've looked up a bunch of guides and went through some of the docs, I think I've got a good idea of how to build this, but in this component we're destructuring props, so how do I pass those into the state variable of history?
export const BaseSummary = ({successState, children}: BaseSummaryProps) => {
let ref = createRef();
const [pdf, setPdf] = useState<any>();
const [finishStatus, setfinishStatus] = useState(false);
const onBackButtonEvent = (e) => {
e.preventDefault();
if (!finishStatus) {
if (window.confirm("Your claim has been submitted, would you like to exit before getting additional claim information?")) {
setfinishStatus(true);
props.history.push(ASSOCIATE_POLICY_SEARCH_ROUTE); // HERE
} else {
window.history.pushState({state: {successState: successState}}, "", window.location.pathname);
setfinishStatus(false);
}
}
};
useEffect(() => {
window.history.pushState(null, "", window.location.pathname);
window.addEventListener('popstate', onBackButtonEvent);
return () => {
window.removeEventListener('popstate', onBackButtonEvent);
};
}, []);
Also I'm not passing in the children var because history does not clone html elements, I just want to pass in the form data that's returned for this component to render the information accordingly
first of all, I think you need to use "useHistory" to handling your hsitry direct without do a lot of complex condition, and you can check more from here
for example:
let history = useHistory();
function handleClick() {
history.push("/home");
}
now, if you need to pass your history via props in this way or via your code, just put it in function and pass function its self, then when you destruct you just need to write your function name...for example:
function handleClick() {
history.push("/home");
}
<MyComponent onClick={handleClick} />
const MyComponent = ({onClick}) => {....}
I fixed it. We're using reach router, so everytime we navigate in our submit forms pages, we use the replace function like so: {replace: true, state: {...stateprops}}. Then I created a common component that overrides the back button functionality, resetting the history stack every time i click back, and using preventdefault to stop it from reloading the page. Then I created a variable to determine whether the window.confirm was pressed, and when it is, I then call history.back().
In some scenarios where we went to external pages outside of the reach router where replace doesn't work, I just used window.history.replaceStack() before the navigate (which is what reach router is essentially doing with their call).
Anyways you wrap this component around wherever you want the back button behavior popup to take effect, and pass in the successState (whatever props you're passing into the current page you're on) in the backButtonBehavior function.
Here is my code:
import React, {useEffect, ReactElement} from 'react';
import { StateProps } from '../Summary/types';
export interface BackButtonBehaviorProps {
children: ReactElement;
successState: StateProps;
}
let isTheBackButtonPressed = false;
export const BackButtonBehavior = ({successState, children}: BackButtonBehaviorProps) => {
const onBackButtonEvent = (e: { preventDefault: () => void; }) => {
e.preventDefault();
if (!isTheBackButtonPressed) {
if (window.confirm("Your claim has been submitted, would you like to exit before getting additional claim information?")) {
isTheBackButtonPressed = true;
window.history.back();
} else {
isTheBackButtonPressed = false;
window.history.pushState({successState: successState}, "success page", window.location.pathname); // When you click back (this refreshes the current instance)
}
} else {
isTheBackButtonPressed = false;
}
};
useEffect(() => {
window.history.pushState(null, "", window.location.pathname);
window.addEventListener('popstate', onBackButtonEvent);
return () => {
window.removeEventListener('popstate', onBackButtonEvent);
};
}, []);
return (children);
};
I have embedded a youtube subscribe button on a single page application that I am building. It loads fine, but when I navigate to a different route, I get the following error message on that route's render:
cb=gapi.loaded_0:150
Uncaught DOMException: Blocked a frame with origin "https://www.youtube.com" from accessing a cross-origin frame.
at Object.nz [as kq] (https://apis.google.com/_/scs/apps-static/_/js/k=oz.gapi.en.xh-S9KbEGSE.O/m=gapi_iframes,gapi_iframes_style_common/rt=j/sv=1/d=1/ed=1/am=wQc/rs=AGLTcCNaUSRWzhd71dAsiMVOstVE3KcJZw/cb=gapi.loaded_0:150:257)
at jz.send (https://apis.google.com/_/scs/apps-static/_/js/k=oz.gapi.en.xh-S9KbEGSE.O/m=gapi_iframes,gapi_iframes_style_common/rt=j/sv=1/d=1/ed=1/am=wQc/rs=AGLTcCNaUSRWzhd71dAsiMVOstVE3KcJZw/cb=gapi.loaded_0:148:261)
at Fz (https://apis.google.com/_/scs/apps-static/_/js/k=oz.gapi.en.xh-S9KbEGSE.O/m=gapi_iframes,gapi_iframes_style_common/rt=j/sv=1/d=1/ed=1/am=wQc/rs=AGLTcCNaUSRWzhd71dAsiMVOstVE3KcJZw/cb=gapi.loaded_0:152:349)
at https://apis.google.com/_/scs/apps-static/_/js/k=oz.gapi.en.xh-S9KbEGSE.O/m=gapi_iframes,gapi_iframes_style_common/rt=j/sv=1/d=1/ed=1/am=wQc/rs=AGLTcCNaUSRWzhd71dAsiMVOstVE3KcJZw/cb=gapi.loaded_0:152:259
This happens even when I go to a route that doesn't have the subscribe button (as long as I was at a route with the button before). My main concern is not the cause of the error, but rather that the subscribe button that is no longer on the page is causing it. Worse still, as I navigate throughout the app, after every route I visit that has the subscribe button, an additional listener (or whatever is causing this) seems to be registered. The message is then printed out multiple times on each route render, once for each button that had been mounted since the last refresh. For reference, I am attaching my component:
import React, { useEffect, useRef } from 'react';
import { observer } from 'mobx-react-lite';
import useScript from 'react-script-hook';
import './YoutubeSubscriberButton.scss';
interface Props {
youtubeChannel: string
}
type gapiType = typeof gapi;
interface gapiYt extends gapiType {
ytsubscribe: {
render: (container: HTMLElement, parameters: {'channel': string, 'layout': string}) => any
}
}
const YoutubeSubscriberButton: React.FC<Props> = observer(({ youtubeChannel }: Props): JSX.Element|null => {
const [gapiLoading, gapiError] = useScript({ src: 'https://apis.google.com/js/platform.js'});
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!youtubeChannel || !container) {
return;
}
while (container.firstChild) {
container.removeChild(container.firstChild);
}
if (gapiLoading || gapiError) {
return;
}
(gapi as gapiYt).ytsubscribe.render(container, {
'channel': youtubeChannel,
'layout': 'full'
});
return (() =>{
while (container && container.firstChild) {
container.removeChild(container.firstChild);
}
});
}, [youtubeChannel, gapiLoading, gapiError]);
return (
<div className="youtube-subscriber-button-component">
<div className="youtube-subscriber-button-container" ref={containerRef}/>
</div>
);
});
export default YoutubeSubscriberButton;
Is there a good way for me to clean up the gapi object, youtube subscribe button iframe, or whatever is causing this?
I made this CodeSandbox based on this solution from Alex McMillan that achieves a Youtube subscribe button. I checked it using React-routes and I had no issues.
You can replace the data-channelid within the App function to see it working.
You can find your unique Youtube Channel ID here:
class="g-ytsubscribe"
data-channelid="<YOUR CHANNEL ID HERE>"
data-layout="full"
data-theme="dark"
data-count="hidden"
I need to show a modal when user wants to leave a specified page.
When User wants to go on a different link from the page, I solve this with getUserConfirmation like that:
const getUserConfirmation = (message, callback) => {
const history = createBrowserHistory({
forceRefresh: true
})
if (history.location.pathname == "/add/car") {
store.dispatch(showModal('ConfirmationLeavingAddPageModal', { callback }));
}
}
The problem is when I press the back button on browser it doesn't work anymore.
Any Help Accepted?
For react-router 2.4.0+
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, () => {
if (history.location.pathname == "/add/car") {
store.dispatch(showModal('ConfirmationLeavingAddPageModal', {
callback
}));
}
})
}
in addition you need to import { withRouter } from 'react-router' and export default withRouter(YourComponent)
I am using ALT for my ReactJS project. I am getting the cannot 'dispatch' error if the ajax call is not yet done and I switch to another page.
Mostly, this is how my project is setup. I have action, store and component. I querying on the server on the componentDidMount lifecycle.
Action:
import alt from '../altInstance'
import request from 'superagent'
import config from '../config'
import Session from '../services/Session'
class EventActions {
findNear(where) {
if (!Session.isLoggedIn()) return
let user = Session.currentUser();
request
.get(config.api.baseURL + config.api.eventPath)
.query(where)
.set('Authorization', 'Token token=' + user.auth_token)
.end((err, res) => {
if (res.body.success) {
this.dispatch(res.body.data.events)
}
});
}
}
export default alt.createActions(EventActions)
Store
import alt from '../altInstance'
import EventActions from '../actions/EventActions'
class EventStore {
constructor() {
this.events = {};
this.rsvp = {};
this.bindListeners({
findNear: EventActions.findNear
});
}
findNear(events) {
this.events = events
}
}
export default alt.createStore(EventStore, 'EventStore')
Component
import React from 'react';
import EventActions from '../../actions/EventActions';
import EventStore from '../../stores/EventStore';
import EventTable from './tables/EventTable'
export default class EventsPage extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true,
events: [],
page: 1,
per: 50
}
}
componentDidMount() {
EventStore.listen(this._onChange.bind(this));
EventActions.findNear({page: this.state.page, per: this.state.per});
}
componentWillUnmount() {
EventStore.unlisten(this._onChange);
}
_onChange(state) {
if (state.events) {
this.state.loading = false;
this.setState(state);
}
}
render() {
if (this.state.loading) {
return <div className="progress">
<div className="indeterminate"></div>
</div>
} else {
return <div className="row">
<div className="col m12">
<h3 className="section-title">Events</h3>
<UserEventTable events={this.state.events}/>
</div>
</div>
}
}
}
componentDidMount() {
EventStore.listen(this._onChange.bind(this));
EventActions.findNear({page: this.state.page, per: this.state.per});
}
This would be my will guess. You are binding onChange which will trigger setState in _onChange, and also an action will be fired from findNear (due to dispatch). So there might be a moment where both are updating at the same moment.
First of all, findNear in my opinion should be as first in componentDidMount.
And also try to seperate it in 2 differnet views (dumb and logic one, where first would display data only, while the other one would do a fetching for example). Also good idea is also to use AltContainer to actually avoid _onChange action which is pretty useless due to the fact that AltContainer has similar stuff "inside".
constructor() {
this.events = {};
this.rsvp = {};
this.bindListeners({
findNear: EventActions.findNear
});
}
findNear(events) {
this.events = events
}
Also I would refactor this one in
constructor() {
this.events = {};
this.rsvp = {};
}
onFindNear(events) {
this.events = events
}
Alt has pretty nice stuff like auto resolvers that will look for the action name + on, so if you have action called findNear, it would search for onFindNear.
I can't quite see why you'd be getting that error because the code you've provided only shows a single action.
My guess however would be that your component has been mounted as a result of some other action in your system. If so, the error would then be caused by the action being triggered in componentDidMount.
Maybe try using Alt's action.defer:
componentDidMount() {
EventStore.listen(this._onChange.bind(this));
EventActions.findNear.defer({page: this.state.page, per: this.state.per});
}
I believe it's because you're calling an action, and the dispatch for that action only occurs when after the request is complete.
I would suggest splitting the findNear action into three actions, findNear, findNearSuccess and findNearFail.
When the component calls findNear, it should dispatch immediately, before even submitting the reuqest so that the relevant components will be updated that a request in progress (e.g. display a loading sign if you like)
and inside the same action, it should call the other action findNearSuccess.
The 'Fetching Data' article should be particularly helpful.
Here is the average Store:
// StoreUser
import EventEmitter from 'eventemitter3';
import Dispatcher from '../dispatcher/dispatcher';
import Constants from '../constants/constants';
import React from 'react/addons';
import serverAddress from '../utils/serverAddress';
import socket from './socket';
var CHANGE_EVENT = "change";
var storedData = {
userName: null
}
socket
.on('newUserName', (newUserName)=>{
storedData.userName = newUserName;
AppStore.emitChange();
})
function _changeUserName (newUserName) {
socket.emit('signUp', newUserName)
}
var AppStore = React.addons.update(EventEmitter.prototype, {$merge: {
emitChange(){
// console.log('emitChange')
this.emit(CHANGE_EVENT);
},
addChangeListener(callback){
this.on(CHANGE_EVENT, callback)
},
removeChangeListener(callback){
this.removeListener(CHANGE_EVENT, callback)
},
getStoredData(callback) {
callback(storedData);
},
dispatcherIndex:Dispatcher.register(function(payload){
var action = payload.action;
switch(action.actionType){
case Constants.CHANGE_USER_NAME:
_changeUserName(payload.action.actionArgument);
break;
}
return true;
})
}});
export default AppStore;
Here is average component, connected to the store:
import React from 'react';
import StoreUser from '../../stores/StoreUser';
import Actions from '../../actions/Actions';
export default class User extends React.Component {
constructor(props) {
super(props);
this.state = {
userName: null,
};
}
componentDidMount() {
StoreUser.addChangeListener(this._onChange.bind(this));
}
componentWillUnmount() {
StoreUser.removeChangeListener();
}
render() {
const { userName } = this.state;
return (
<div key={userName}>
{userName}
</div>
);
}
_onChange(){
StoreUser.getStoredData(_callbackFunction.bind(this))
function _callbackFunction (storedData) {
// console.log('storedData', storedData)
this.setState({
userName: storedData.userName,
})
}
}
}
This was working absolutely fine. But now, I'm facing quite big mistake in this approach:
WebApp has, say, sidebar with user's name, which is listening to StoreUser in order to display up to date userName. And also, the webapp has a component (page), which contains user's profile (including userName), which is also listening to StoreUser. If I will unmount this component, then the sidebar will stop listening to StoreUser too.
I've learned this approach from egghead.io or some other tutorial - I don't remember, where exactly. Seems, I've missed some important point about how to manage store listeners. Could you, please, suggest simple approach to solve the described issue?
I'm trying to stick to the original Flux architecture, please, don't advice me any alternative implementations of Flux architecture. This is the only issue I have.
The issue is that you aren't removing the your listener when you unmount. You have to be very careful to remove exactly the same function reference that you added (passing this.callback.bind(this) twice doesn't work, since the two calls return different function references). The simplest approach would be:
//...
export default class User extends React.Component {
constructor(props) {
super(props);
this.state = {
userName: null,
};
// create fixed bound function reference we can add and remove
// (like the auto-binding React.createComponent does for you).
this._onChange = this._onChange.bind(this);
}
componentDidMount() {
StoreUser.addChangeListener(this._onChange);
}
componentWillUnmount() {
// make sure you remove your listener!
StoreUser.removeChangeListener(this._onChange);
}
//...
There are a couple of oddities with your code:
Why does StoreUser.getStoredData take a callback, rather than just returning the data? With flux, you should always query synchronously. If data needs fetching, the getter could fire off the ajax fetch, return null, and then trigger another action when the ajax request returns, causing another emitChange and hence ultimately triggering the re-render with the user data.
It looks like your store is triggering server writes. Normally, in flux, this would be the responsibility of an action creator rather than the store, though I don't think it's a major violation for the store to do it.