How to display component using react-router <Prompt> to prevent or allow route change - javascript

I'm currently trying to find a way to display a custom component (like a Modal) to confirm route changes using the Prompt component.
The default behavior of the Promp component is to show a confirm dialog with a message, as you can see in this Example: React Router: Preventing Transitions.
Note: I am using the <BrowserRouter> component.
The router has a prop named getUserConfirmation, which you can use to customize the behavior of the <Prompt> component.
// this is the default behavior
function getConfirmation(message, callback) {
const allowTransition = window.confirm(message);
callback(allowTransition);
}
<BrowserRouter getUserConfirmation={getConfirmation} />;
What I'm trying to do:
Inside the parent component APP
I'm setting the confirm state to true, to display the <Confirm> component
And I'm trying to pass the callback from the getConfirmation function to the <Confirm> component to call it with true to allow transition, and with false to prevent it.
The callback would be called with true or false in the default behavior as you can see above.
function getConfirmation(message, callback) {
console.log("Inside getConfirmation function...");
setConfirmCallback(callback);
setConfirm(true);
// const allowTransition = window.confirm(message);
// callback(allowTransition);
}
This is what App.js is rendering:
return (
<Router getUserConfirmation={getConfirmation}>
<AllRoutes />
{confirm && (
<Confirm confirmCallback={confirmCallback} setConfirm={setConfirm} />
)}
</Router>
);
What seems to be the problem:
The confirm dialog seems to block the function at that point. So the callback variable/parameter is still in scope. So everything works OK.
When I remove the confirm dialog, that function runs all the way. And when I click on the confirm button inside the <Confirm> component, the callback no longer exists.
QUESTION
Does anybody know a way to achieve this behavior (preventing route changes using a custom component instead of a confirm dialog) using react-router-dom?
Link to CodeSandbox
Full code from CodeSandbox:
import React, { useState } from "react";
import ReactDOM from "react-dom";
import {
BrowserRouter as Router,
Route,
Switch,
Link,
Prompt
} from "react-router-dom";
import "./styles.css";
function App() {
console.log("Rendering App...");
const [confirm, setConfirm] = useState(false);
const [confirmCallback, setConfirmCallback] = useState(null);
function getConfirmation(message, callback) {
console.log("Inside getConfirmation function...");
setConfirmCallback(callback);
setConfirm(true);
// const allowTransition = window.confirm(message);
// callback(allowTransition);
}
return (
<Router getUserConfirmation={getConfirmation}>
<AllRoutes />
{confirm && (
<Confirm confirmCallback={confirmCallback} setConfirm={setConfirm} />
)}
</Router>
);
}
function Confirm(props) {
function allowTransition() {
props.setConfirm(false);
props.confirmCallback(true);
}
function blockTransition() {
props.setConfirm(false);
props.confirmCallback(false);
}
return (
<React.Fragment>
<div>Are you sure?</div>
<button onClick={allowTransition}>Yes</button>
<button onClick={blockTransition}>No way</button>
</React.Fragment>
);
}
function AllRoutes(props) {
console.log("Rendering AllRoutes...");
return (
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/comp1" component={Component1} />
</Switch>
);
}
function Home(props) {
console.log("Rendering Home...");
return (
<React.Fragment>
<div>This is Home</div>
<ul>
<li>
<Link to="/comp1">Component1</Link>
</li>
</ul>
</React.Fragment>
);
}
function Component1(props) {
console.log("Rendering Component1...");
const [isBlocking, setIsBlocking] = useState(true);
return (
<React.Fragment>
<Prompt
when={isBlocking}
message={location =>
`Are you sure you want to go to ${location.pathname}`
}
/>
<div>This is component 1</div>
<Link to="/">Home</Link>
</React.Fragment>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Inspired by this discussion and by this example, I was able to make my example working.
The problem was that when the <Confirm> was being created, the setConfirmCallback() call wasn't done yet. So the <Confirm> component wasn't able to use the callback from getUserConfirmation.
So I've changed this line:
FROM:
setConfirmCallback(callback);
TO:
setConfirmCallback(()=>callback);
And now it works!
CodeSandbox Link
Full CodeSandbox code:
import React, { useState } from "react";
import ReactDOM from "react-dom";
import {
BrowserRouter as Router,
Route,
Switch,
Link,
Prompt
} from "react-router-dom";
import "./styles.css";
function App() {
console.log("Rendering App...");
const [confirm, setConfirm] = useState(false);
const [confirmCallback, setConfirmCallback] = useState(null);
function getConfirmation(message, callback) {
console.log("Inside getConfirmation function...");
setConfirmCallback(() => callback);
setConfirm(true);
// const allowTransition = window.confirm(message);
// callback(allowTransition);
}
return (
<Router getUserConfirmation={getConfirmation}>
<AllRoutes />
{confirm && (
<Confirm confirmCallback={confirmCallback} setConfirm={setConfirm} />
)}
</Router>
);
}
function Confirm(props) {
console.log("Rendering Confirm...");
function allowTransition() {
props.setConfirm(false);
props.confirmCallback(true);
}
function blockTransition() {
props.setConfirm(false);
props.confirmCallback(false);
}
return (
<React.Fragment>
<div>Are you sure?</div>
<button onClick={allowTransition}>Yes</button>
<button onClick={blockTransition}>No way</button>
</React.Fragment>
);
}
function AllRoutes(props) {
console.log("Rendering AllRoutes...");
return (
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/comp1" component={Component1} />
</Switch>
);
}
function Home(props) {
console.log("Rendering Home...");
return (
<React.Fragment>
<div>This is Home</div>
<ul>
<li>
<Link to="/comp1">Component1</Link>
</li>
</ul>
</React.Fragment>
);
}
function Component1(props) {
console.log("Rendering Component1...");
const [isBlocking, setIsBlocking] = useState(true);
return (
<React.Fragment>
<Prompt
when={isBlocking}
message={location =>
`Are you sure you want to go to ${location.pathname}`
}
/>
<div>This is component 1</div>
<Link to="/">Home</Link>
</React.Fragment>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

I found a simple workaround for my case. I could not share the whole component but snippets.
// this will initiate the dialogbox render and
// prevent the window from going back by returning false
const backButtonPressed = async () => {
leavePrompt(false);
return false;
}
// this will open the prompt dialog box
const leavePrompt = (endRoom) => {
setOpenPrompt({open: true, action: endRoom ? "endRoom" : "leaveQuitely"});
}
// render
<Dialog open={openPrompt.open} aria-labelledby="interim-user-dialog-title">
<DialogContent dividers>
<Typography variant="h6" gutterBottom>
Are you sure?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenPrompt({...openPrompt, open: false})} color="primary">
Stay
</Button>
<Button onClick={() => history.push("/")} color="secondary">
Leave
</Button>
</DialogActions>
</Dialog>
// when allowedToGoBack state is true then call a method that will render the dialog box
<Prompt
when={true}
title={"Alert"}
message={() => allowedToGoBack ? backButtonPressed() && false : true}
/>

Related

How to correctly pass a callback and a state to the Layout element in React Router Dom?

How to correctly pass callbacks and states to the Layout so that they can be used elsewhere? When I share this as below, I have errors and a white screen:
class Menu extends Component {
constructor(props) {
super(props);
this.onSearchF = this.onSearchF.bind(this)
}
state = {
searchBlock: false,
};
onSearchF = (keyword) => {
const filtered = this.state.data.filter((entry) =>
Object.values(entry).some(
(val) => typeof val === "string" && val.toLowerCase().includes(keyword.toLowerCase())
)
);
};
render() {
return (
<div className="content">
<Routes>
<Route path="/" element={<Layout searchBlock={this.state.searchBlock} onSearch={()=>this.onSearchF()}/>}>
<Route
index
element={
<Home data={this.state.data} num={this.state.data.length} />
}
/>
</Route>
</Routes>
</div>
);
}
}
export default Menu;
Here I pass the callback to the Header that I previously passed to the Layout:
const Layout = () => {
return (
<>
<Header sblock={this.props.searchBlock} onS = {this.props.onSearch}/>
</>
);
};
export default Layout;
I want to use the callback here:
function Header() {
return (
<header className="header">
<button onClick={()=>console.log(this.props.sblock)}>button</button>
</header>
);
}
export default Header;
Your Layout is a functional component, and you are trying to use this.props in it; this is incorrect. Get the props as part of arguments instead, like so:
import { Outlet } from "react-router-dom";
const Layout = ({searchBlock,onSearch}) => {
return (
<>
<Header sblock={searchBlock} onS={onSearch}/>
<Outlet/>
</>
);
};
export default Layout;
Issues
The Layout component isn't accepting any props.
The Layout component isn't rendering an Outlet for nested routes.
Solution
It seems that Layout only exists to render the Header component. I'd suggest rendering Header directly in the Main component.
Example:
class Menu extends Component {
state = {
data: [],
searchBlock: false,
};
onSearch = (keyword) => {
const filtered = this.state.data.filter((entry) =>
Object.values(entry).some((val) =>
typeof val === "string"
&& val.toLowerCase().includes(keyword.toLowerCase())
)
);
... do something with filtered ...
};
render() {
const { data, searchBlock } = this.state;
return (
<div className="content">
<Header sblock={searchBlock} onS={this.onSearch} />
<Routes>
<Route
path="/"
element={<Home data={data} num={data.length} />}
/>
</Routes>
</div>
);
}
}
export default Menu;

Calling function from parent gives 'Uncaught TypeError: showSnack is not a function'

Since Snackbars instant closes when navigating pages I had to put Snackbar in parent, but for some reason it doesn't work. Everything looks the same as the other posts. The error says that showSnack in Child isn't a function.
In Child we have a function in the prop that gets called with the button.
Simplified Child:
export default function Child({ showSnack }) {
return (
<Button variant="contained"
onClick={() => {
// navigate('/home');
showSnack('Application submitted for review');
}
}>
Confirm
</Button>
);
}
In Parent there is the actual function. The function is also passed into the Child as a prop of the same name.
Simplified App.js (Parent):
function App() {
const [snackOpen, setOpenSnack] = React.useState(false);
const [snackText, setSnackText] = React.useState('');
function showSnack(snackText) {
setSnackText(snackText);
setOpenSnack(true);
}
const handleCloseSnack = (event, reason) => {
if (reason === "clickaway") {
return;
}
setOpenSnack(false);
};
return (
<div>
<Navbar />
<BrowserRouter>
<Routes>
<Route path="/">
<Route path="child" element={<Child showSnack={showSnack} />} />
</Route>
</Routes>
</BrowserRouter>
<Snackbar
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
open={snackOpen}
autoHideDuration={2000}
// onClose={handleCloseSnack}
message={snackText}
/>
</div>
);
}
export default App;
EDIT (solution): I had to refresh the page

How can i access the history object inside of this promise call?

In the React component, A button submission takes the values of text fields on the page and passes them into mockFetch. Then, If the promise of mockFetch is successful, It causes a history.push('/newaccount') to fire.
I set up my test to click on the button and then attempt to detect the history.push('/newaccount') call with what i passed into it, but my mock history.push doesn't get called. Does anyone know what i can do to get this test to pass?
EDIT: It turns out replacing the current jest.mock with:
const history = createMemoryHistory();
const pushSpy = await jest.spyOn(history, "push");
allows me to call the mocked history when the history.push is OUTSIDE of the mockFetch function... but not when it is inside of it. That's what i'm trying to figure out now.
Main app routing:
function App() {
const classes = useStyles();
return (
<div className="App">
<Banner />
<div id="mainSection" className={classes.root}>
<ErrorBoundary>
<Paper>
<Router history={history}>
<Switch>
<Route path="/newaccount">
<NewAccountPage />
</Route>
<Route path="/disqualify">
<DisqualificationPage />
</Route>
<Route path="/">
<LandingPage />
</Route>
</Switch>
</Router>
</Paper>
</ErrorBoundary>
</div>
</div>
);
}
export default App;
Component: (omitted some redundant fields)
import React, { useCallback, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useHistory } from "react-router-dom";
import Grid from "#material-ui/core/Grid";
import Button from "#material-ui/core/Button";
import TextFieldStyled from "../TextFieldStyled/TextFieldStyled.js";
import * as actionTypes from "../../redux/actions/rootActions.js";
import {
checkAllErrors,
} from "../../validators/validators.js";
import mockFetch from "../../fetchCall/fetchCall";
import "./LandingPage.css";
const LandingPage = () => {
const dispatch = useDispatch();
const history = useHistory();
const {
changeCarPrice,
changeErrorMessage,
resetState,
} = actionTypes;
useEffect(() => {
return () => {
dispatch({ type: resetState });
};
}, [dispatch, resetState]);
const { carPrice } = useSelector(
(state) => state
);
const handleSubmit = (e) => {
e.preventDefault();
if (!checkAllErrors(allErrors)) {
// Call the API
mockFetch(carPrice)
.then((response) => {
history.push("/newaccount");
})
.catch((error) => {
dispatch({ type: changeErrorMessage, payload: error });
history.push("/disqualify");
});
}
};
const [allErrors, setAllErrors] = useState({
carValueError: false,
});
return (
<div id="landingPage">
<Grid container>
<Grid item xs={2} />
<Grid item xs={8}>
<form onSubmit={handleSubmit}>
<Grid container id="internalLandingPageForm" spacing={4}>
{/* Text Fields */}
<Grid item xs={6}>
<TextFieldStyled
info={"Enter Car Price ($):"}
value={carPrice}
adornment={"$"}
type="number"
label="required"
required
error={allErrors.carValueError}
id="carPriceField"
helperText={
allErrors.carValueError &&
"Please enter a value below 1,000,000 dollars"
}
passbackFunction={(e) => handleChange(e, changeCarPrice)}
/>
</Grid>
</Grid>
<Grid container id="internalLandingPageFormButton" spacing={4}>
<Grid item xs={4} />
<Grid item xs={3}>
<Button
variant="contained"
color="primary"
type="submit"
id="applyNowButton"
title="applyNowButton"
onSubmit={handleSubmit}
>
Apply Now
</Button>
</Grid>
<Grid item xs={5} />
</Grid>
</form>
</Grid>
<Grid item xs={2} />
</Grid>
</div>
);
};
export default LandingPage;
Test:
const wrapWithRedux = (component) => {
return <Provider store={store}>{component}</Provider>;
};
it("simulates a successful submission form process", () => {
const mockHistoryPush = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useHistory: () => ({
push: mockHistoryPush,
}),
}));
render(
wrapWithRedux(
<Router history={history}>
<Switch>
<Route path="/newaccount">
<NewAccountPage />
</Route>
<Route path="/">
<LandingPage />
</Route>
</Switch>
</Router>
)
);
const carPriceField= screen.getByTestId("carPriceField");
fireEvent.change(carPriceField, { target: { value: "5000" } });
const buttonSubmission= screen.getByTitle("buttonSubmission");
fireEvent.click(buttonSubmission);
expect(mockHistoryPush).toHaveBeenCalledWith('/newaccount');
});
so after looking further into mocking history, i found this from How to mock useHistory hook in jest?
The answer seems to be to do the code below and then the pushSpy will be called
const history = createMemoryHistory();
const pushSpy = jest.spyOn(history, "push");
render(
wrapWithRedux(
<Router history={history}>
<Switch>
<Route path="/newaccount">
<NewAccountPage />
</Route>
<Route path="/">
<LandingPage />
</Route>
</Switch>
</Router>
)
);
I also had to wrap the expect line using waitFor from the react testing library to get the history.push to call the mock inside of the mockFetch function like so:
await waitFor(() => expect(pushSpy).toHaveBeenCalledWith("/newaccount"));
With these two modifications, the test now passes.
Rather than mock a bunch of things and test implementation, I would test the behavior.
Here are some ideas to maybe help writing for behavior over implementation details.
Refer to React Router testing docs
React Router has a guide on testing navigation based on actions.
Hijack render() to wrap with Redux
Rather than writing a Redux wrapper for every test suite, you could hijack the react-testing-library's render() function to get a clean state or seed the state. Redux has docs here.
Don't mock fetch()
A button submission takes the values of text fields on the page and passes them into mockFetch
I would use an HTTP interceptor to stub a response. That way you get the async behavior and you bind your tests to the backend vs binding it to the tool. Say you don't like fetch(), you'll be stuck with it until you migrate everything. I made a blog post on the subject Testing components that make API calls.
Here's your code example with some edits:
it("creates a new account", () => { // <-- more descriptive behavior
// Stub the server response
nock(`${yoursite}`)
.post('/account/new') // <-- or whatever your backend is
.reply(200);
render(
<MemoryRouter initialEntries={["/"]}> // <-- Start where your forms are at
<Switch>
<Route path="/newaccount">
<NewAccountPage />
</Route>
<Route path="/">
<LandingPage />
</Route>
</Switch>
</MemoryRouter>
);
const carPriceField= screen.getByTestId("carPriceField");
fireEvent.change(carPriceField, { target: { value: "5000" } });
const buttonSubmission= screen.getByTitle("buttonSubmission");
fireEvent.click(buttonSubmission);
expect(document.body.textContent).toBe('New Account Page'); // <-- Tests route change
});

React - useContext returns undefined

i'm trying to use React Context to manage authentication, but i can't see the value that return the context in PrivateRoute.js
App.js
render() {
return (
<>
<BrowserRouter>
<Islogin>
<Header/>
<Banner/>
<Switch>
<PrivateRoute exact path="/index" component={Landing} />
<PrivateRoute path="/upload" component={Upload} exact />
<PublicRoute restricted={false} path="/unauth" component={Unauthorized} exact />
</Switch>
</Islogin>
</BrowserRouter>
</>
);
}
}
export default App;
the console log of isAuthenticated returns undefined
PrivateRoute.js
const PrivateRoute = ({component: Component, ...rest}) => {
const isAuthenticated = useContext(AuthContext)
console.log(isAuthenticated)
const [validCredentials, setValidCredentials] = React.useState(false)
React.useEffect(() => {
if (typeof isAuthenticated === 'boolean') {
setValidCredentials(isAuthenticated)
}
}, [isAuthenticated])
return (
// Show the component only when the user is logged in
// Otherwise, redirect the user to /signin page
<Route {...rest} render={props => (
validCredentials ?
<Component {...props} />
: <Redirect to="/unauth" />
)} />
);
};
export default PrivateRoute;
IsLogin.js
The api call works and the console log shows true.
export default function Islogin({ children }) {
var [auth, setAuth] = React.useState(false)
React.useEffect(() =>{
axios.post('/api/auth').then(response => {
var res = response.data.result;
console.log("try")
console.log(res)
setAuth(res)
})
},[])
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
)
}
You may need to import it at the top of the file that you are using it in (PrivateRoute.js)
Try this:
import {useContext} from 'react'

the logout component not rendering after the authenticated is turned to true this is similar code as from react-router docs

I just tried to build the react-router docs ex on browser but there is problem in AuthButton component it isn't showing signOut button when the isAuthenticated turns true
import React from 'react';
import {
BrowserRouter as Router,
Switch,
Route,
Link,
Redirect,
useHistory,
useLocation,
} from 'react-router-dom';
export default function AuthExample() {
return (
<Router>
<div>
<AuthButton />
<ul>
<li>
<Link to='/public'>Public Page</Link>
</li>
<li>
<Link to='/protected'>Protected Page</Link>
</li>
</ul>
<Switch>
<Route path='/public'>
<PublicPage />
</Route>
<Route path='/login'>
<LoginPage />
</Route>
<PrivateRoute path='/protected'>
<ProtectedPage />
</PrivateRoute>
</Switch>
</div>
</Router>
);
}
const fakeAuth = {
isAuthenticated: false,
authenticate(cb) {
fakeAuth.isAuthenticated = true;
setTimeout(cb, 100); // fake async
},
signout(cb) {
fakeAuth.isAuthenticated = false;
setTimeout(cb, 100);
},
};
function AuthButton() {
let history = useHistory();
return fakeAuth.isAuthenticated ? (
<p>
Welcome!{' '}
<button
onClick={() => {
fakeAuth.signout(() => history.push('/'));
}}>
Sign out
</button>
</p>
) : (
<p>You are not logged in.</p>
);
}
function PrivateRoute({ children, ...rest }) {
return (
<Route
{...rest}
render={({ location }) =>
fakeAuth.isAuthenticated ? (
children
) : (
<Redirect
to={{
pathname: '/login',
state: { from: location },
}}
/>
)
}
/>
);
}
function PublicPage() {
return <h3>Public</h3>;
}
function ProtectedPage() {
return <h3>Protected</h3>;
}
function LoginPage() {
let history = useHistory();
let location = useLocation();
let { from } = location.state || { from: { pathname: '/' } };
let login = () => {
fakeAuth.authenticate(() => {
history.replace(from);
});
};
return (
<div>
<p>You must log in to view the page at {from.pathname}</p>
<button onClick={login}>Log in</button>
</div>
);
}
The reason it's not updating is because it doesn't know to update. You change the route but AuthButton doesn't know to re-render based on the route you need to pass it a prop so that it knows when to update. I refactored your code to incorporate using react hooks. By using hooks you can store isAuthenticated in local state in AuthExample via useState.
From AuthExample, pass down the state value for isAuthenticated as a prop to AuthButton. If the prop changes, AuthButton will detect it and this will trigger a re-render of AuthButton and reflect the correct component structure you are looking for. See below.
import React, { useState } from "react";
import {
BrowserRouter as Router,
Switch,
Route,
Link,
Redirect,
useHistory,
useLocation
} from "react-router-dom";
export default function AuthExample() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const fakeAuth = {
isAuthenticated: isAuthenticated,
authenticate(cb) {
fakeAuth.isAuthenticated = true;
setIsAuthenticated(true);
setTimeout(cb, 100); // fake async
},
signout(cb) {
setIsAuthenticated(false);
fakeAuth.isAuthenticated = false;
setTimeout(cb, 100);
}
};
return (
<Router>
<div>
<AuthButton fakeAuth={fakeAuth} isAuthenticated={isAuthenticated} />
<ul>
<li>
<Link to="/public">Public Page</Link>
</li>
<li>
<Link to="/protected">Protected Page</Link>
</li>
</ul>
<Switch>
<Route path="/public">
<PublicPage />
</Route>
<Route path="/login">
<LoginPage fakeAuth={fakeAuth} />
</Route>
<PrivateRoute path="/protected" fakeAuth={fakeAuth}>
<ProtectedPage />
</PrivateRoute>
</Switch>
</div>
</Router>
);
}
function AuthButton(props) {
const { fakeAuth, isAuthenticated } = props;
let history = useHistory();
return isAuthenticated ? (
<p>
Welcome!{" "}
<button
onClick={() => {
fakeAuth.signout(() => history.push("/"));
}}
>
Sign out
</button>
</p>
) : (
<p>You are not logged in.</p>
);
}
function PrivateRoute({ children, ...rest }) {
const { fakeAuth } = rest;
return (
<Route
{...rest}
render={({ location }) =>
fakeAuth.isAuthenticated ? (
children
) : (
<Redirect
to={{
pathname: "/login",
state: { from: location }
}}
/>
)
}
/>
);
}
function PublicPage() {
return <h3>Public</h3>;
}
function ProtectedPage() {
return <h3>Protected</h3>;
}
function LoginPage(props) {
const { fakeAuth } = props;
let history = useHistory();
let location = useLocation();
let { from } = location.state || { from: { pathname: "/" } };
let login = () => {
fakeAuth.authenticate(() => {
history.replace(from);
});
};
return (
<div>
<p>You must log in to view the page at {from.pathname}</p>
<button onClick={login}>Log in</button>
</div>
);
}
You can also see a working example in this code sandbox. There are a few ways to do this but hooks make it easy to manipulate state values to update functional components without having to make them class components. This way also keeps most of your code intact as is just adding a few checks for when isAuthenticated is updated.
I think the problem is in rendering process.
In my opinion, if you put the sub-functions in to the exported function, this problem may solve.
If the problem won't solve, try the class base component for handling this rendering process.
wish you success

Categories

Resources