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 */

Move api.ts to its own folder

From ./src/hooks/api.ts to ./src/api/api.ts. Your IDE should update the dependencies should automatically.

Update the hooks

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);
  }
}

Modify the e2e commands

Between the 3 files under ./cypress/support, we can compartmentalize the imports in favor of relevance

  • commands.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)>
      >;
    }
  }
}

Create a boys mirror of the heroes

Update ./db.json

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."
    }
  ]
}

Add fixture ./cypress/fixtures/boys.json

[
  {
    "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 the components

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>
        &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/boys/BoyList.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 { Boy } from "models/Boy";
import { BoyProperty } from "models/types";

type BoyListProps = {
  boys: Boy[];
  handleDeleteBoy: (boy: Boy) => (e: MouseEvent<HTMLButtonElement>) => void;
};

export default function BoyList({ boys, handleDeleteBoy }: BoyListProps) {
  const deferredBoys = useDeferredValue(boys);
  const isStale = deferredBoys !== boys;
  const [filteredBoys, setFilteredBoys] = useState(deferredBoys);
  const navigate = useNavigate();
  const [isPending, startTransition] = useTransition();

  // needed to refresh the list after deleting a boy
  useEffect(() => setFilteredBoys(deferredBoys), [deferredBoys]);

  // currying: the outer fn takes our custom arg and returns a fn that takes the event
  const handleSelectBoy = (boyId: string) => () => {
    const boy = deferredBoys.find((b: Boy) => b.id === boyId);
    navigate(
      `/boys/edit-boy/${boy?.id}?name=${boy?.name}&description=${boy?.description}`
    );
  };

  /** returns a boolean whether the boy properties exist in the search field */
  const searchExists = (searchProperty: BoyProperty, 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: Boy[]) =>
    [...data].filter((item: Boy) =>
      Object.values(item).find((property: BoyProperty) =>
        searchExists(property, searchField)
      )
    );

  /** filters the boys data to see if the any of the properties exist in the list */
  const handleSearch =
    (data: Boy[]) => (event: ChangeEvent<HTMLInputElement>) => {
      const searchField = event.target.value;

      return startTransition(() =>
        setFilteredBoys(searchProperties(searchField, data))
      );
    };

  return (
    <div
      style={{
        opacity: isPending ? 0.5 : 1,
        color: isStale ? "dimgray" : "black",
      }}
    >
      {deferredBoys.length > 0 && (
        <div className="card-content">
          <span>Search </span>
          <input data-cy="search" onChange={handleSearch(deferredBoys)} />
        </div>
      )}
      &nbsp;
      <ul data-cy="boy-list" className="list">
        {filteredBoys.map((boy, index) => (
          <li data-cy={`boy-list-item-${index}`} key={boy.id}>
            <div className="card">
              <CardContent name={boy.name} description={boy.description} />
              <footer className="card-footer">
                <ButtonFooter
                  label="Delete"
                  IconClass={FaRegSave}
                  onClick={handleDeleteBoy(boy)}
                />
                <ButtonFooter
                  label="Edit"
                  IconClass={FaEdit}
                  onClick={handleSelectBoy(boy.id)}
                />
              </footer>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}
// src/boys/Boys.tsx
import { useState } from "react";
import { useNavigate, Routes, Route } from "react-router-dom";
import ListHeader from "components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import ErrorComp from "components/ErrorComp";
import BoyList from "./BoyList";
import BoyDetail from "./BoyDetail";
import { useGetEntities } from "hooks/useGetEntities";
import { useDeleteEntity } from "hooks/useDeleteEntity";
import { Boy } from "models/Boy";

export default function Boys() {
  const [showModal, setShowModal] = useState<boolean>(false);
  const { entities: boys, getError } = useGetEntities("boys");
  const [boyToDelete, setBoyToDelete] = useState<Boy | null>(null);
  const { deleteEntity: deleteBoy, isDeleteError } = useDeleteEntity("boy");

  const navigate = useNavigate();
  const addNewBoy = () => navigate("/boys/add-boy");
  const handleRefresh = () => navigate("/boys");

  const handleCloseModal = () => {
    setBoyToDelete(null);
    setShowModal(false);
  };
  // currying: the outer fn takes our custom arg and returns a fn that takes the event
  const handleDeleteBoy = (boy: Boy) => () => {
    setBoyToDelete(boy);
    setShowModal(true);
  };
  const handleDeleteFromModal = () => {
    boyToDelete ? deleteBoy(boyToDelete) : null;
    setShowModal(false);
  };

  if (getError || isDeleteError) {
    return <ErrorComp />;
  }

  return (
    <div data-cy="boys">
      <ListHeader
        title="Boys"
        handleAdd={addNewBoy}
        handleRefresh={handleRefresh}
      />
      <div>
        <div>
          <Routes>
            <Route
              path=""
              element={
                <BoyList boys={boys} handleDeleteBoy={handleDeleteBoy} />
              }
            />
            <Route path="/add-boy" element={<BoyDetail />} />
            <Route path="/edit-boy/:id" element={<BoyDetail />} />
            <Route
              path="*"
              element={
                <BoyList boys={boys} handleDeleteBoy={handleDeleteBoy} />
              }
            />
          </Routes>
        </div>
      </div>

      {showModal && (
        <ModalYesNo
          message="Would you like to delete the boy?"
          onNo={handleCloseModal}
          onYes={handleDeleteFromModal}
        />
      )}
    </div>
  );
}

Add Boys route to App.tsx

// src/App.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import { ErrorBoundary } from "react-error-boundary";
import HeaderBar from "components/HeaderBar";
import NavBar from "components/NavBar";
import PageSpinner from "components/PageSpinner";
import ErrorComp from "components/ErrorComp";
import Villains from "villains/Villains";
import Boys from "boys/Boys";
import "./styles.scss";
const Heroes = lazy(() => import("heroes/Heroes"));
const NotFound = lazy(() => import("components/NotFound"));
const About = lazy(() => import("About"));

const queryClient = new QueryClient();

export default function App() {
  return (
    <BrowserRouter>
      <HeaderBar />
      <div className="section columns">
        <NavBar />
        <main className="column">
          <Q