Prerequisite

Create new interfaces and types that will be utilized throughout the hero and villain groups of components.
// src/models/Villain.ts
export interface Villain {
id: string;
name: string;
description: string;
}
// src/models/types.ts
import { Hero } from "./Hero";
import { Villain } from "./Villain";
export type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];
export type VillainProperty =
| Villain["name"]
| Villain["description"]
| Villain["id"];
export type EntityRoute = "heroes" | "villains";
export type EntityType = "hero" | "villain";

Update the hooks

We want to make our hooks more generic so that they can seamlessly be used in the villains group of components. In short, we will replace useCRUDhero hooks with useCRUDentity.
useDeleteEntity replaces useDeleteHero.
// src/hooks/useDeleteEntity.ts
import { Hero } from "models/Hero";
import { EntityType } from "models/types";
import { Villain } from "models/Villain";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { deleteItem } from "./api";
/**
* Helper for DELETE to `/heroes` or `/villains` routes.
* @returns {object} {deleteEntity, isDeleting, isDeleteError, deleteError}
*/
export function useDeleteEntity(entityType: EntityType) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation(
(item: Hero | Villain) => deleteItem(`${entityRoute}/${item.id}`),
{
// on success receives the original item as a second argument
// if you recall, the first argument is the created item
onSuccess: (_, deletedEntity: Hero | Villain) => {
// get all the entities from the cache
const entities: Hero[] | Villain[] =
queryClient.getQueryData([`${entityRoute}`]) || [];
// set the entities cache without the delete one
queryClient.setQueryData(
[`${entityRoute}`],
entities.filter((h) => h.id !== deletedEntity.id)
);
navigate(`/${entityRoute}`);
},
}
);
return {
deleteEntity: mutation.mutate,
isDeleting: mutation.isLoading,
isDeleteError: mutation.isError,
deleteError: mutation.error,
};
}
useEntityParams replaces useHeroParams.
// src/hooks/useEntityParams.ts
import { useSearchParams } from "react-router-dom";
export function useEntityParams() {
const [searchParams] = useSearchParams();
const name = searchParams.get("name");
const description = searchParams.get("description");
return { name, description };
}
useGetEntities replaces useGetHeroes.
// src/hooks/useGetEntities.ts
import { EntityRoute } from "models/types";
import { useQuery } from "react-query";
import { getItem } from "./api";
/**
* Helper for GET to `/heroes` or `/villains` routes
* @returns {object} {entities, status, getError}
*/
export const useGetEntities = (entityRoute: EntityRoute) => {
const query = useQuery(entityRoute, () => getItem(entityRoute), {
suspense: true,
});
return {
entities: query.data,
status: query.status,
getError: query.error,
};
};
usePostEntity replaces usePostHero.
// src/hooks/usePostEntity.ts
import { Hero } from "models/Hero";
import { EntityType } from "models/types";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { createItem } from "./api";
/**
* Helper for simple POST to `/heroes` route
* @returns {object} {mutate, status, error}
*/
export function usePostEntity(entityType: EntityType) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation((item: Hero) => createItem(entityRoute, item), {
onSuccess: (newData: Hero) => {
// use queryClient's setQueryData to set the cache
// takes a key as the first arg, the 2nd arg is a cb that takes the old query cache and returns the new one
queryClient.setQueryData([entityRoute], (oldData: Hero[] | undefined) => [
...(oldData || []),
newData,
]);
return navigate(`/${entityRoute}`);
},
});
}
usePutEntity replaces usePutHero.
// src/hooks/usePutEntity.ts
import { Hero } from "models/Hero";
import { useMutation, useQueryClient } from "react-query";
import type { QueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { editItem } from "./api";
import { Villain } from "models/Villain";
import { EntityType } from "models/types";
/**
* Helper for PUT to `/heroes` route
* @returns {object} {updateHero, isUpdating, isUpdateError, updateError}
*/
export function usePutEntity(entityType: EntityType) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
const queryClient = useQueryClient();
const navigate = useNavigate();
const mutation = useMutation(
(item: Hero) => editItem(`${entityRoute}/${item.id}`, item),
{
onSuccess: (updatedEntity: Hero) => {
updateEntityCache(entityType, updatedEntity, queryClient);
navigate(`/${entityRoute}`);
},
}
);
return {
updateEntity: mutation.mutate,
isUpdating: mutation.isLoading,
isUpdateError: mutation.isError,
updateError: mutation.error,
};
}
/** Replace a hero in the cache with the updated version. */
function updateEntityCache(
entityType: EntityType,
updatedEntity: Hero | Villain,
queryClient: QueryClient
) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
// get all the heroes from the cache
let entityCache: Hero[] | Villain[] =
queryClient.getQueryData(entityRoute) || [];
// find the index in the cache of the hero that's been edited
const entityIndex = entityCache.findIndex((h) => h.id === updatedEntity.id);
if (entityIndex !== -1) {
// if the hero is found, replace the pre-edited hero with the updated one
// this is just replacing an array item in place,
// while not mutating the original array
entityCache = entityCache.map((preEditedEntity) =>
preEditedEntity.id === updatedEntity.id ? updatedEntity : preEditedEntity
);
console.log("entityCache is", entityCache);
// use queryClient's setQueryData to set the cache
// takes a key as the first arg, the 2nd arg is the new cache
return queryClient.setQueryData([entityRoute], entityCache);
} else return null;
}

Update the hero components

Having changed the hooks, the hero group of components need slight modifications.
// src/heroes/HeroDetail.tsx
import { useState, ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { FaUndo, FaRegSave } from "react-icons/fa";
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
import PageSpinner from "components/PageSpinner";
import ErrorComp from "components/ErrorComp";
import { useEntityParams } from "hooks/useEntityParams";
import { usePostEntity } from "hooks/usePostEntity";
import { Hero } from "models/Hero";
import { usePutEntity } from "hooks/usePutEntity";
export default function HeroDetail() {
const { id } = useParams();
const { name, description } = useEntityParams();
const [hero, setHero] = useState({ id, name, description });
const {
mutate: createHero,
status,
error: postError,
} = usePostEntity("hero");
const {
updateEntity: updateHero,
isUpdating,
isUpdateError,
} = usePutEntity("hero");
const navigate = useNavigate();
const handleCancel = () => navigate("/heroes");
const handleSave = () =>
name ? updateHero(hero as Hero) : createHero(hero as Hero);
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setHero({ ...hero, name: e.target.value });
};
const handleDescriptionChange = (e: ChangeEvent<HTMLInputElement>) => {
setHero({ ...hero, description: e.target.value });
};
if (status === "loading" || isUpdating) {
return <PageSpinner />;
}
if (postError || isUpdateError) {
return <ErrorComp />;
}
return (
<div data-cy="hero-detail" className="card edit-detail">
<header className="card-header">
<p className="card-header-title">{name}</p>
&nbsp;
</header>
<div className="card-content">
<div className="content">
{id && (
<InputDetail name={"id"} value={id} readOnly={true}></InputDetail>
)}
<InputDetail
name={"name"}
value={name ? name : ""}
placeholder="e.g. Colleen"
onChange={handleNameChange}
></InputDetail>
<InputDetail
name={"description"}
value={description ? description : ""}
placeholder="e.g. dance fight!"
onChange={handleDescriptionChange}
></InputDetail>
</div>
</div>
<footer className="card-footer">
<ButtonFooter
label="Cancel"
IconClass={FaUndo}
onClick={handleCancel}
/>
<ButtonFooter label="Save" IconClass={FaRegSave} onClick={handleSave} />
</footer>
</div>
);
}
// src/heroes/Heroes.tsx
import { useState } from "react";
import { useNavigate, Routes, Route } from "react-router-dom";
import ListHeader from "components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import PageSpinner from "components/PageSpinner";
import ErrorComp from "components/ErrorComp";
import HeroList from "./HeroList";
import HeroDetail from "./HeroDetail";
import { useGetEntities } from "hooks/useGetEntities";
import { useDeleteEntity } from "hooks/useDeleteEntity";
import { Hero } from "models/Hero";
export default function Heroes() {
const [showModal, setShowModal] = useState<boolean>(false);
const { entities: heroes, status, getError } = useGetEntities("heroes");
const [heroToDelete, setHeroToDelete] = useState<Hero | null>(null);
const { deleteEntity: deleteHero, isDeleteError } = useDeleteEntity("hero");
const navigate = useNavigate();
const addNewHero = () => navigate("/heroes/add-hero");
const handleRefresh = () => navigate("/heroes");
const handleCloseModal = () => {
setHeroToDelete(null);
setShowModal(false);
};
const handleDeleteHero = (hero: Hero) => () => {
setHeroToDelete(hero);
setShowModal(true);
};
const handleDeleteFromModal = () => {
heroToDelete ? deleteHero(heroToDelete) : null;
setShowModal(false);
};
if (status === "loading") {
return <PageSpinner />;
}
if (getError || isDeleteError) {
return <ErrorComp />;
}
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<Routes>
<Route
path=""
element={
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
}
/>
<Route path="/add-hero" element={<HeroDetail />} />
<Route path="/edit-hero/:id" element={<HeroDetail />} />
<Route
path="*"
element={
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
}
/>
</Routes>
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={handleDeleteFromModal}
/>
)}
</div>
);
}
// src/heroes/HeroList.tsx
import { useNavigate } from "react-router-dom";
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import {
ChangeEvent,
MouseEvent,
useTransition,
useEffect,
useState,
useDeferredValue,
} from "react";
import { Hero } from "models/Hero";
import { HeroProperty } from "models/types";
type HeroListProps = {
heroes: Hero[];
handleDeleteHero: (hero: Hero) => (e: MouseEvent<HTMLButtonElement>) => void;
};
export default function HeroList({ heroes, handleDeleteHero }: HeroListProps) {
const deferredHeroes = useDeferredValue(heroes);
const isStale = deferredHeroes !== heroes;
const [filteredHeroes, setFilteredHeroes] = useState(deferredHeroes);
const navigate = useNavigate();
const [isPending, startTransition] = useTransition();
// needed to refresh the list after deleting a hero
useEffect(() => setFilteredHeroes(deferredHeroes), [deferredHeroes]);
const handleSelectHero = (heroId: string) => () => {
const hero = deferredHeroes.find((h: Hero) => h.id === heroId);
navigate(
`/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}`
);
};
/** returns a boolean whether the hero properties exist in the search field */
const searchExists = (searchProperty: HeroProperty, searchField: string) =>
String(searchProperty).toLowerCase().indexOf(searchField.toLowerCase()) !==
-1;
/** given the data and the search field, returns the data in which the search field exists */
const searchProperties = (searchField: string, data: Hero[]) =>
[...data].filter((item: Hero) =>
Object.values(item).find((property: HeroProperty) =>
searchExists(property, searchField)
)
);
/** filters the heroes data to see if the any of the properties exist in the list */
const handleSearch =
(data: Hero[]) => (event: ChangeEvent<HTMLInputElement>) => {
const searchField = event.target.value;
return startTransition(() =>
setFilteredHeroes(searchProperties(searchField, data))
);
};
return (
<div
style={{
opacity: isPending ? 0.5 : 1,
color: isStale ? "dimgray" : "black",
}}
>
{deferredHeroes.length > 0 && (
<div className="card-content">
<span>Search </span>
<input data-cy="search" onChange={handleSearch(deferredHeroes)} />
</div>
)}
&nbsp;
<ul data-cy="hero-list" className="list">
{filteredHeroes.map((hero, index) => (
<li data-cy={`hero-list-item-${index}`} key={hero.id}>
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteHero(hero)}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectHero(hero.id)}
/>
</footer>
</div>
</li>
))}
</ul>
</div>
);
}

Note about testing implementation details

There are no modifications needed to Cypress e2e, CT, or RTL tests with MSW because we did not test implementation details in any of them. They will all work as they are at the moment.
With cy.intercept and MSW we checked that a network request goes out versus checking that the operation caused a hook to be called. Consequently changing the hooks had no impact on the tests, or the functionality. This is why we want to test at a slightly higher level of abstraction, and why we want to verify the consequences of the implementation vs the implementation details themselves.

Modify the e2e commands to be more generic

The e2e tests for villains will look exactly the same, however our commands are a specific to heroes. They can also be more generic so that mirroring the hero group of tests into villains is easier.
In the commands file we will change most references to hero to entity, and for types we will include the villain varieties next to hero. The change will require small updates to type definitions and e2e tests.
// cypress/support/commands.ts
import { Villain } from "./../../src/models/Villain";
import { Hero } from "../../src/models/Hero";
import {
EntityRoute,
EntityType,
HeroProperty,
VillainProperty,
} from "../../src/models/types";
import data from "../fixtures/db.json";
Cypress.Commands.add("getByCy", (selector, ...args) =>
cy.get(`[data-cy="${selector}"]`, ...args)
);
Cypress.Commands.add("getByCyLike", (selector, ...args) =>
cy.get(`[data-cy*=${selector}]`, ...args)
);
Cypress.Commands.add("getByClassLike", (selector, ...args) =>
cy.get(`[class*=${selector}]`, ...args)
);
Cypress.Commands.add(
"crud",
(
method: "GET" | "POST" | "PUT" | "DELETE",
route: string,
{
body,
allowedToFail = false,
}: { body?: Hero | object; allowedToFail?: boolean } = {}
) =>
cy.request<Hero[] & Hero>({
method: method,
url: `${Cypress.env("API_URL")}/${route}`,
body: method === "POST" || method === "PUT" ? body : undefined,
retryOnStatusCodeFailure: !allowedToFail,
failOnStatusCode: !allowedToFail,
})
);
Cypress.Commands.add("resetData", () =>
cy.crud("POST", "reset", { body: data })
);
const { _ } = Cypress;
const propExists =
(property: HeroProperty | VillainProperty) => (entity: Hero | Villain) =>
entity.name === property ||
entity.description === property ||
entity.id === property;
const getEntities = (entityRoute: EntityRoute) =>
cy.crud("GET", entityRoute).its("body");
Cypress.Commands.add(
"getEntityByProperty",
(entityType: EntityType, property: HeroProperty | VillainProperty) =>
getEntities(entityType === "hero" ? "heroes" : "villains").then(
(entities) => _.find(entities, propExists(property))
)
);
Cypress.Commands.add(
"findEntityIndex",
(entityType: EntityType, property: HeroProperty | VillainProperty) =>
getEntities(entityType === "hero" ? "heroes" : "villains").then(
(body: Hero[]) => ({
entityIndex: _.findIndex(body, propExists(property)),
entityArray: body,
})
)
);
Cypress.Commands.add("visitStubbedEntities", (entityRoute: EntityRoute) => {
cy.intercept("GET", `${Cypress.env("API_URL")}/${entityRoute}`, {
fixture: `${entityRoute}.json`,
}).as(`stubbed${_.startCase(entityRoute)}`);
cy.visit(`/${entityRoute}`);
cy.wait(`@stubbed${_.startCase(entityRoute)}`);
return cy.location("pathname").should("eq", `/${entityRoute}`);
});
Cypress.Commands.add("visitEntities", (entityRoute: EntityRoute) => {
cy.intercept("GET", `${Cypress.env("API_URL")}/${entityRoute}`).as(
`get${_.startCase(entityRoute)}`
);
cy.visit(`/${entityRoute}`);
cy.wait(`@get${_.startCase(entityRoute)}`);
return cy.location("pathname").should("eq", `/${entityRoute}`);
});
// cypress.d.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { MountOptions, MountReturn } from "cypress/react";
import { HeroProperty, VillainProperty, EntityType } from "models/types";
import type { Hero } from "./cypress/support/commands";
export {};
declare global {
namespace Cypress {
interface Chainable {
/** Yields elements with a data-cy attribute that matches a specified selector.
* ```
* cy.getByCy('search-toggle') // where the selector is [data-cy="search-toggle"]
* ```
*/
getByCy(qaSelector: string, args?: any): Chainable<JQuery<HTMLElement>>;
/** Yields elements with data-cy attribute that partially matches a specified selector.
* ```
* cy.getByCyLike('chat-button') // where the selector is [data-cy="chat-button-start-a-new-claim"]
* ```
*/
getByCyLike(
qaSelector: string,
args?: any
): Chainable<JQuery<HTMLElement>>;
/** Yields the element that partially matches the css class
* ```
* cy.getByClassLike('StyledIconBase') // where the class is class="StyledIconBase-ea9ulj-0 lbJwfL"
* ```
*/
getByClassLike(
qaSelector: string,
args?: any
): Chainable<JQuery<HTMLElement>>;
/** Mounts a React node
* @param component React Node to mount
* @param options Additional options to pass into mount
*/
mount(
component: React.ReactNode,
options?: MountOptions
): Cypress.Chainable<MountReturn>;
/** Mounts the component wrapped by all the providers:
* QueryClientProvider, ErrorBoundary, Suspense, BrowserRouter
* @param component React Node to mount
* @param options Additional options to pass into mount
*/
wrappedMount(
component: React.ReactNode,
options?: MountOptions
): Cypress.Chainable<MountReturn>;
/** Visits heroes or villains routes, uses real network, verifies path */
visitEntities(entityRoute: EntityRoute): Cypress.Chainable<string>;
/** Visits heroes or villains routes, uses stubbed network, verifies path */
visitStubbedEntities(entityRoute: EntityRoute): Cypress.Chainable<string>;
/**
* Gets an entity by name.
* ```js
* cy.getEntityByName(newHero.name).then(myHero => ...)
* ```
* @param name: Hero['name']
*/
getEntityByProperty(
entityType: EntityType,
property: HeroProperty | VillainProperty
): Cypress.Chainable<Hero | Villain>;
/**
* Given a hero property (name, description or id),
* returns the index of the hero, and the entire collection, as an object.
*/
findEntityIndex(
entityType: EntityType,
property: HeroProperty
): Cypress.Chainable<{ entityIndex: number; entityArray: Hero[] }>;
/**
* Performs crud operations GET, POST, PUT and DELETE.
*
* `body` and `allowedToFail are optional.
*
* If they are not passed in, body is empty but `allowedToFail` still is `false`.
*
* If the body is passed in and the method is `POST` or `PUT`, the payload will be taken,
* otherwise undefined for `GET` and `DELETE`.
* @param method
* @param route
* @param options: {body?: Hero | object; allowedToFail?: boolean}
*/
crud(
method: "GET" | "POST" | "PUT" | "DELETE",
route: string,
{
body,
allowedToFail = false,
}: { body?: Hero | object; allowedToFail?: boolean } = {}
): Cypress.Chainable<Response<Hero[] & Hero>>;
/**
* Resets the data in the database to the initial data.
*/
resetData(): Cypress.Chainable<Response<Hero[] & Hero>>;
}
}
}
// cypress/e2e/create-hero.cy.ts
import { faker } from "@faker-js/faker";
describe("Create hero", () => {
before(cy.resetData);
const navToAddHero = () => {
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
};
it("should go through the refresh flow (ui-integration)", () => {
cy.visitStubbedEntities("heroes");
navToAddHero();
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the cancel flow and perform direct navigation (ui-integration)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
fixture: "heroes",
}).as("stubbedGetHeroes");
cy.visit("/heroes/add-hero");
cy.wait("@stubbedGetHeroes");
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the add hero flow (ui-e2e)", () =>