ch14-Heroes-part2-react-router
We are back in the
Heroes
component, and this time we have routing capabilities. On initial render, Heroes
displays its child HeroList
component. We need it to be able to display HeroDetail
when clicking the + button of the ListHeader
. Then we need HeroList
displayed again when clicking the refresh button of the ListHeader
. Cancel
button should go back from HeroDetail
to HeroList
. Create a new branch feat/Heroes-part2

HeroesPart2-initial
For the time being, instead of switching between
HeroList
and HerdoDetail
depending on the route, we can display them both together. Let's write the test (Red 1).// src/heroes/Heroes.cy.tsx
import Heroes from "./Heroes";
import { BrowserRouter } from "react-router-dom";
import "../styles.scss";
describe("Heroes", () => {
it("should handle hero add and refresh", () => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("list-header");
cy.getByCy("add-button").click();
cy.get("@log").should("have.been.calledWith", "handleAdd");
cy.getByCy("refresh-button").click();
cy.get("@log").should("have.been.calledWith", "handleRefresh");
});
it.only("should display hero list on render", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("hero-list");
cy.getByCy("hero-detail");
});
const invokeHeroDelete = () => {
cy.getByCy("delete-button").first().click();
cy.getByCy("modal-yes-no").should("be.visible");
};
it("should go through the modal flow", () => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("modal-yes-no").should("not.exist");
cy.log("do not delete flow");
invokeHeroDelete();
cy.getByCy("button-no").click();
cy.getByCy("modal-yes-no").should("not.exist");
cy.log("delete flow");
invokeHeroDelete();
cy.getByCy("button-yes").click();
cy.getByCy("modal-yes-no").should("not.exist");
cy.get("@log").should("have.been.calledWith", "handleDeleteFromModal");
});
});
To pass the test, we add the
HeroDetail
near HeroList
. It has a prop hero
, which can temporarily be index 0 of the heroes
array (Green 1).// src/heroes/Heroes.tsx
import ListHeader from "components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
import { useState } from "react";
import HeroDetail from "./HeroDetail";
export default function Heroes() {
const [showModal, setShowModal] = useState<boolean>(false);
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
const handleCloseModal = () => {
setShowModal(false);
};
const handleDeleteHero = () => {
setShowModal(true);
};
const handleDeleteFromModal = () => {
setShowModal(false);
console.log("handleDeleteFromModal");
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
<HeroDetail hero={heroes[0]} />
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={handleDeleteFromModal}
/>
)}
</div>
);
}

HeroesPart2-Green1
We want to switch the displayed component when the route changes. What drives this in React is first the route, then the child component, as we saw in the
react-router
chapter. In a component test, on the initial mount there is no url, but when clicking a link the route changes. We can drive the test with the refresh and + buttons to check the route. We will use cy.location
in this test to check for the pathname. Here is an excerpt from Gleb Bahmutov's Cypress tips:cy.visit("https://example.cypress.io/commands/location?search=value#top");
// yields a specific part of the location
cy.location("protocol").should("equal", "https:");
cy.location("hostname").should("equal", "example.cypress.io");
cy.location("pathname").should("equal", "/commands/location");
cy.location("search").should("equal", "?search=value");
cy.location("hash").should("equal", "#top");
For brevity, we will keep the test code focused on the
.only
section. We write a test that checks that when clicking the refresh button the path becomes /heroes
(Red 2).// src/heroes/Heroes.cy.tsx
it.only("should display hero list on render", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("hero-list");
cy.getByCy("hero-detail");
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/heroes");
});
The test fails, but in the console we see
handleRefresh
log. Instead of the log, we can have something that changes the url. React-router's useNavigate
can be used for this purpose which lets us programmatically navigate to any url (Green 2).// src/heroes/Heroes.tsx
import { useNavigate } from "react-router-dom";
import ListHeader from "components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
import { useState } from "react";
import HeroDetail from "./HeroDetail";
export default function Heroes() {
const [showModal, setShowModal] = useState<boolean>(false);
const addNewHero = () => console.log("handleAdd");
const navigate = useNavigate();
const handleRefresh = () => navigate("/heroes");
const handleCloseModal = () => {
setShowModal(false);
};
const handleDeleteHero = () => {
setShowModal(true);
};
const handleDeleteFromModal = () => {
setShowModal(false);
console.log("handleDeleteFromModal");
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
<HeroDetail hero={heroes[0]} />
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={handleDeleteFromModal}
/>
)}
</div>
);
}
We can now try another test that clicks on the add button and checks the url. We wish for that path to be
add-hero
(Red 3).// src/heroes/Heroes.cy.tsx
it.only("should display hero list on render", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("hero-list");
cy.getByCy("hero-detail");
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
});
Similar to the previous cycle, we see
handleAdd
being console.logged. We can utilize useNavigate
once more (Green 3).// src/heroes/Heroes.tsx
import { useNavigate } from "react-router-dom";
import ListHeader from "components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
import { useState } from "react";
import HeroDetail from "./HeroDetail";
export default function Heroes() {
const [showModal, setShowModal] = useState<boolean>(false);
const navigate = useNavigate();
const addNewHero = () => navigate("/heroes/add-hero");
const handleRefresh = () => navigate("/heroes");
const handleCloseModal = () => {
setShowModal(false);
};
const handleDeleteHero = () => {
setShowModal(true);
};
const handleDeleteFromModal = () => {
setShowModal(false);
console.log("handleDeleteFromModal");
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
<HeroDetail hero={heroes[0]} />
</div>
</div>