Prerequisite
Create new interfaces and types that will be utilized throughout the hero and villain groups of components.
// src/models/Boy.ts
export interface Boy {
id: string;
name: string;
description: string;
}
/* istanbul ignore file */
// src/models/types.ts
import { Hero } from "./Hero";
import { Villain } from "./Villain";
import { Boy } from "./Boy";
export type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];
export type VillainProperty =
| Villain["name"]
| Villain["description"]
| Villain["id"];
export type BoyProperty = Boy["name"] | Boy["description"] | Boy["id"];
export type EntityRoute = "heroes" | "villains" | "boys";
export type EntityType = "hero" | "villain" | "boy";
/** Returns the corresponding route for the entity;
*
* `hero` -> `/heroes`, `villain` -> `/villains`, `boy` -> `/boys` */
export const entityRoute = (entityType: EntityType) =>
entityType === "hero"
? "heroes"
: entityType === "villain"
? "villains"
: "boys";
/* istanbul ignore file */
From
./src/hooks/api.ts
to ./src/api/api.ts
. Your IDE should update the dependencies should automatically.useDeleteEntity
gets updated for Boy
..// src/hooks/useDeleteEntity.ts
import { Boy } from "models/Boy";
import { Hero } from "models/Hero";
import { EntityType, entityRoute } from "models/types";
import { Villain } from "models/Villain";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { deleteItem } from "../api/api";
/**
* Helper for DELETE to `/heroes`, `/villains` or 'boys' routes.
* @returns {object} {deleteEntity, isDeleting, isDeleteError, deleteError}
*/
export function useDeleteEntity(entityType: EntityType) {
const route = entityRoute(entityType);
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation(
(item: Hero | Villain | Boy) => deleteItem(`${route}/${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 | Boy) => {
// get all the entities from the cache
const entities: Hero[] | Villain[] | Boy[] =
queryClient.getQueryData([`${route}`]) || [];
// set the entities cache without the delete one
queryClient.setQueryData(
[`${route}`],
entities.filter((h) => h.id !== deletedEntity.id)
);
navigate(`/${route}`);
},
}
);
return {
deleteEntity: mutation.mutate,
isDeleting: mutation.isLoading,
isDeleteError: mutation.isError,
deleteError: mutation.error,
};
}
useGetEntities
just needs the comment to be updated- Helper for GET to
/heroes
or/villains
routes - Helper for GET to
/heroes
,/villains
or/boys
routes.
usePostEntity
gets updated for Boy
.// src/hooks/usePostEntity.ts
import { Boy } from "models/Boy";
import { Hero } from "models/Hero";
import { EntityType, entityRoute } from "models/types";
import { Villain } from "models/Villain";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { createItem } from "../api/api";
/**
* Helper for simple POST to `/heroes`, `/villains`, `/boys` routes
* @returns {object} {mutate, status, error}
*/
export function usePostEntity(entityType: EntityType) {
const route = entityRoute(entityType);
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation((item: Hero | Villain | Boy) => createItem(route, item), {
onSuccess: (newData: Hero | Villain | Boy) => {
// 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(
[route],
(oldData: Hero[] | Villain[] | Boy[] | undefined) => [
...(oldData || []),
newData,
]
);
return navigate(`/${route}`);
},
});
}
usePutEntity
gets updated for Boys
.// src/hooks/usePutEntity.ts
import { Hero } from "models/Hero";
import { Boy } from "models/Boy";
import { Villain } from "models/Villain";
import { EntityType, entityRoute } from "models/types";
import { useMutation, useQueryClient } from "react-query";
import type { QueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { editItem } from "../api/api";
/**
* Helper for PUT to `/heroes` route
* @returns {object} {updateHero, isUpdating, isUpdateError, updateError}
*/
export function usePutEntity(entityType: EntityType) {
const route = entityRoute(entityType);
const queryClient = useQueryClient();
const navigate = useNavigate();
const mutation = useMutation(
(item: Hero | Villain | Boy) => editItem(`${route}/${item.id}`, item),
{
onSuccess: (updatedEntity: Hero | Villain | Boy) => {
updateEntityCache(entityType, updatedEntity, queryClient);
navigate(`/${route}`);
},
}
);
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 | Boy,
queryClient: QueryClient
) {
const route = entityRoute(entityType);
// get all the heroes from the cache
let entityCache: Hero[] | Villain[] | Boy[] =
queryClient.getQueryData(route) || [];
// 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 entity is found, replace the pre-edited entity 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([route], entityCache);
}
}
Between the 3 files under
./cypress/support
, we can compartmentalize the imports in favor of relevancecommands.ts
: all plugin imports, and commands common to e2e & CT (ex:cy.getByCY
).component.ts
: the commands specific to component tests (ex:cy.mount
,cy.wrappedMount
).e2e.ts
: the commands specific to e2e (ex:cy.crud
).
// cypress/support/commands.ts
import "@testing-library/cypress/add-commands";
import "@bahmutov/cypress-code-coverage/support";
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)
);
component.tsx
and e2e.ts
will import commands.ts
so that CT and e2e files have access to common commands and plugins.// cypress/support/component.tsx
import "./commands";
import { mount } from "cypress/react18";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import { ErrorBoundary } from "react-error-boundary";
import ErrorComp from "../../src/components/ErrorComp";
import PageSpinner from "../../src/components/PageSpinner";
import { Suspense } from "react";
Cypress.Commands.add("mount", mount);
Cypress.Commands.add(
"wrappedMount",
(WrappedComponent: React.ReactNode, options = {}) => {
const wrapped = (
<QueryClientProvider client={new QueryClient()}>
<ErrorBoundary fallback={<ErrorComp />}>
<Suspense fallback={<PageSpinner />}>
<BrowserRouter>{WrappedComponent}</BrowserRouter>
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
return cy.mount(wrapped, options);
}
);
// cypress/support/e2e.ts
import "./commands";
import { Villain } from "./../../src/models/Villain";
import { Hero } from "../../src/models/Hero";
import { Boy } from "../../src/models/Boy";
import {
entityRoute,
EntityRoute,
EntityType,
HeroProperty,
VillainProperty,
BoyProperty,
} from "../../src/models/types";
import data from "../fixtures/db.json";
Cypress.Commands.add(
"crud",
(
method: "GET" | "POST" | "PUT" | "DELETE",
route: string,
{
body,
allowedToFail = false,
}: { body?: Hero | Villain | Boy | object; allowedToFail?: boolean } = {}
) =>
cy.request<(Hero[] & Hero) | (Villain[] & Villain) | (Boy[] & Boy)>({
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 | BoyProperty) =>
(entity: Hero | Villain | Boy) =>
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 | BoyProperty
) =>
getEntities(entityRoute(entityType)).then((entities) =>
_.find(entities, propExists(property))
)
);
Cypress.Commands.add(
"findEntityIndex",
(
entityType: EntityType,
property: HeroProperty | VillainProperty | BoyProperty
) =>
getEntities(entityRoute(entityType)).then(
(body: Hero[] | Villain[] | Boy[]) => ({
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}`);
});
Update the type definitions in the repo root.
// 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 "./models/types/Hero";
import type { Villain } from "./models/types/Villain";
import type { Boy } from "./models/types/Boy";
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 | Boy>;
/**
* 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[] | Villain[] | Boy[];
}>;
/**
* 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) | (Villain[] & Villain) | (Boy[] & Boy)>
>;
}
}
}
Add
boys
group.{
"heroes": [
{
"id": "HeroAslaug",
"name": "Aslaug",
"description": "warrior queen"
},
{
"id": "HeroBjorn",
"name": "Bjorn Ironside",
"description": "king of 9th century Sweden"
},
{
"id": "HeroIvar",
"name": "Ivar the Boneless",
"description": "commander of the Great Heathen Army"
},
{
"id": "HeroLagertha",
"name": "Lagertha the Shieldmaiden",
"description": "aka Hlaðgerðr"
},
{
"id": "HeroRagnar",
"name": "Ragnar Lothbrok",
"description": "aka Ragnar Sigurdsson"
},
{
"id": "HeroThora",
"name": "Thora Town-hart",
"description": "daughter of Earl Herrauðr of Götaland"
}
],
"villains": [
{
"id": "VillainMadelyn",
"name": "Madelyn",
"description": "the cat whisperer"
},
{
"id": "VillainHaley",
"name": "Haley",
"description": "pen wielder"
},
{
"id": "VillainElla",
"name": "Ella",
"description": "fashionista"
},
{
"id": "VillainLandon",
"name": "Landon",
"description": "Mandalorian mauler"
}
],
"boys": [
{
"id": "BoyHomelander",
"name": "Homelander",
"description": "Like Superman, but a jerk."
},
{
"id": "BoyAnnieJanuary",
"name": "Annie January",
"description": "The Defender of Des Moines."
},
{
"id": "BoyBillyButcher",
"name": "Billy Butcher",
"description": "A former member of the British special forces turned vigilante."
},
{
"id": "BoyBlackNoir",
"name": "Black Noir",
"description": "Master Martial Artist, expert hand-to-hand combatant highly trained in various forms of martial arts."
}
]
}
Mirror it to
./cypress/fixtures/db.json
so that we can reset the db state correctly.{
"heroes": [
{
"id": "HeroAslaug",
"name": "Aslaug",
"description": "warrior queen"
},
{
"id": "HeroBjorn",
"name": "Bjorn Ironside",
"description": "king of 9th century Sweden"
},
{
"id": "HeroIvar",
"name": "Ivar the Boneless",
"description": "commander of the Great Heathen Army"
},
{
"id": "HeroLagertha",
"name": "Lagertha the Shieldmaiden",
"description": "aka Hlaðgerðr"
},
{
"id": "HeroRagnar",
"name": "Ragnar Lothbrok",
"description": "aka Ragnar Sigurdsson"
},
{
"id": "HeroThora",
"name": "Thora Town-hart",
"description": "daughter of Earl Herrauðr of Götaland"
}
],
"villains": [
{
"id": "VillainMadelyn",
"name": "Madelyn",
"description": "the cat whisperer"
},
{
"id": "VillainHaley",
"name": "Haley",
"description": "pen wielder"
},
{
"id": "VillainElla",
"name": "Ella",
"description": "fashionista"
},
{
"id": "VillainLandon",
"name": "Landon",
"description": "Mandalorian mauler"
}
],
"boys": [
{
"id": "BoyHomelander",
"name": "Homelander",
"description": "Like Superman, but a jerk."
},
{
"id": "BoyAnnieJanuary",
"name": "Annie January",
"description": "The Defender of Des Moines."
},
{
"id": "BoyBillyButcher",
"name": "Billy Butcher",
"description": "A former member of the British special forces turned vigilante."
},
{
"id": "BoyBlackNoir",
"name": "Black Noir",
"description": "Master Martial Artist, expert hand-to-hand combatant highly trained in various forms of martial arts."
}
]
}
[
{
"id": "BoyHomelander",
"name": "Homelander",
"description": "Like Superman, but a jerk."
},
{
"id": "BoyAnnieJanuary",
"name": "Annie January",
"description": "The Defender of Des Moines."
},
{
"id": "BoyBillyButcher",
"name": "Billy Butcher",
"description": "A former member of the British special forces turned vigilante."
},
{
"id": "BoyBlackNoir",
"name": "Black Noir",
"description": "Master Martial Artist, expert hand-to-hand combatant highly trained in various forms of martial arts."
}
]
We are creating 3 components for boys, mirroring heroes group as they are. We also have to update some of the base components.
For
ListHeader
only the type changes for title
.// src/components/ListHeader.tsx
import { MouseEvent } from "react";
import { NavLink } from "react-router-dom";
import { FiRefreshCcw } from "react-icons/fi";
import { GrAdd } from "react-icons/gr";
type ListHeaderProps = {
title: "Heroes" | "Villains" | "Boys" | "About";
handleAdd: (e: MouseEvent<HTMLButtonElement>) => void;
handleRefresh: (e: MouseEvent<HTMLButtonElement>) => void;
};
export default function ListHeader({
title,
handleAdd,
handleRefresh,
}: ListHeaderProps) {
return (
<div data-cy="list-header" className="content-title-group">
<NavLink data-cy="title" to={title}>
<h2>{title}</h2>
</NavLink>
<button data-cy="add-button" onClick={handleAdd} aria-label="add">
<GrAdd />
</button>
<button
data-cy="refresh-button"
onClick={handleRefresh}
aria-label="refresh"
>
<FiRefreshCcw />
</button>
</div>
);
}
The rest are mirrors. Create a folder
./src/boys/
and the 3 mirror files under it.// src/boys/BoyDetail.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 { Boy } from "models/Boy";
import { usePutEntity } from "hooks/usePutEntity";
export default function BoyDetail() {
const { id } = useParams();
const { name, description } = useEntityParams();
const [boy, setBoy] = useState({ id, name, description });
const { mutate: createBoy, status, error: postError } = usePostEntity("boy");
const {
updateEntity: updateBoy,
isUpdating,
isUpdateError,
} = usePutEntity("boy");
const navigate = useNavigate();
const handleCancel = () => navigate("/boys");
const handleSave = () =>
name ? updateBoy(boy as Boy) : createBoy(boy as Boy);
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setBoy({ ...boy, name: e.target.value });
};
const handleDescriptionChange = (e: ChangeEvent<HTMLInputElement>) => {
setBoy({ ...boy, description: e.target.value });
};
if (status === "loading" || isUpdating) {
return <PageSpinner />;
}
if (postError || isUpdateError) {
return <ErrorComp />;
}
return (
<div data-cy="boy-detail" className="card edit-detail">
<header className="card-header">
<p className="card-header-title">{name}</p>
</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>