Bu bölüme başlamadan önce, uygulamayı Kahramanlar ve Kötüler arasında daha genel hale getirmek için bölümünden geçtiğinizden emin olun.
Bu bölümde, kahramanları kötülere yansıtacağız ve Context api'yi kötülere uygulayacağız. Context api, değeri bileşen ağacının derinliklerine geçirmemize olanak tanır, bunu her bileşenden açıkça geçirerek yapmamız gerekmez. Bu, bileşen ağacında durumu paylaşmak için context'i kullanmak ile durumu alt bileşenlere prop olarak geçirmek arasında güzel bir karşılaştırma sunacaktır.
Heroes.tsx, heroes özelliğini HeroList.tsx'e aktarır.
HeroDetail, hero{id, name, description} durumunu URL'den useParams ve useSearchParams ile alır.
Bu, uygulamamızdaki durum yönetiminin sınırlarıdır. Gerçekte context'e ihtiyacımız yoktur, ancak context api ile şeylerin nasıl farklı olabileceğini inceleyeceğiz.
Kötüler için Context API
Şu anda kahramanlardan kötülere tam bir yansıma elde ettik, işlevsellik ve testler tam olarak aynı şekilde gerçekleştiriliyor. Bununla birlikte, kötüler grubunu değiştirecek ve bunu yaparken Context api'den faydalanacağız.
Villains.tsx, villains özelliğini VillainList.tsx'e aktarır. Bunun yerine Context api'yi kullanarak villains'in Villains.txt altındaki tüm bileşenlerde kullanılabilir olmasını sağlayacağız.
Context api ile genel adımlar şunlardır:
Context'i oluşturun ve dışa aktarın. Genellikle bu ayrı bir dosyada bulunur ve arabulucu görevi görür.
// src/villains/VillainsContext.tsx (the common node)
import { Villain } from "models/Villain";
import { createContext } from "react";
const VillainsContext = createContext<Villain[]>([]);
export default VillainsContext;
Alt bileşenlere geçirilecek durumu belirleyin. Orada context'i içe aktarın.
Örneğimizde, Villains.tsx dosyasından, villains'i VillainDetail.tsx'e geçiriyoruz. villains'i useGetEntity adlı kancadan alıyoruz. Şu anda villains'i bir özellik olarak VillainDetail.tsx'e geçiriyoruz ve bunun yerine context api'yi kullanmak istiyoruz. Bu yüzden context'i içe aktarıyoruz ve context sağlayıcıyı, içinde villains'e atanmış bir value özelliği olan rotalarla sarıyoruz (Adımlar 2, 3).
VillainsList.tsx bileşenine context aracılığıyla durumu iletmemiz gerekiyor. Bu nedenle context'i ve React'tan useContext'i içe aktarıyoruz. Paylaşılan context'i argüman olarak içeren useContext'i çağırıyoruz ve villains adlı bir değişkene atıyoruz (Adımlar 4, 5). Şimdi, özelliğin yerine, durumu VillainsContext'ten alıyoruz.
// src/villains/VillainList.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 { useContext } from "react";
import { Villain } from "models/Villain";
import VillainsContext from "./VillainsContext";
type VillainListProps = {
handleDeleteVillain: (
villain: Villain
) => (e: MouseEvent<HTMLButtonElement>) => void;
};
export default function VillainList({ handleDeleteVillain }: VillainListProps) {
const villains = useContext(VillainsContext);
const deferredVillains = useDeferredValue(villains);
const isStale = deferredVillains !== villains;
const [filteredVillains, setFilteredVillains] = useState(deferredVillains);
const navigate = useNavigate();
const [isPending, startTransition] = useTransition();
// needed to refresh the list after deleting a villain
useEffect(() => setFilteredVillains(deferredVillains), [deferredVillains]);
const handleSelectVillain = (villainId: string) => () => {
const villain = deferredVillains.find((h: Villain) => h.id === villainId);
navigate(
`/villains/edit-villain/${villain?.id}?name=${villain?.name}&description=${villain?.description}`
);
};
type VillainProperty =
| Villain["name"]
| Villain["description"]
| Villain["id"];
/** returns a boolean whether the villain properties exist in the search field */
const searchExists = (searchProperty: VillainProperty, 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: Villain[]) =>
[...data].filter((item: Villain) =>
Object.values(item).find((property: VillainProperty) =>
searchExists(property, searchField)
)
);
/** filters the villains data to see if the any of the properties exist in the list */
const handleSearch =
(data: Villain[]) => (event: ChangeEvent<HTMLInputElement>) => {
const searchField = event.target.value;
return startTransition(() =>
setFilteredVillains(searchProperties(searchField, data))
);
};
return (
<div
style={{
opacity: isPending ? 0.5 : 1,
color: isStale ? "dimgray" : "black",
}}
>
{deferredVillains.length > 0 && (
<div className="card-content">
<span>Search </span>
<input data-cy="search" onChange={handleSearch(deferredVillains)} />
</div>
)}
<ul data-cy="villain-list" className="list">
{filteredVillains.map((villain, index) => (
<li data-cy={`villain-list-item-${index}`} key={villain.id}>
<div className="card">
<CardContent
name={villain.name}
description={villain.description}
/>
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteVillain(villain)}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectVillain(villain.id)}
/>
</footer>
</div>
</li>
))}
</ul>
</div>
);
}
Bileşen testini güncelleyin ve bağlama sırasında context sağlayıcıyı da kullanın.
// src/villains/VillainList.cy.tsx
import VillainList from "./VillainList";
import "../styles.scss";
import villains from "../../cypress/fixtures/villains.json";
import VillainsContext from "./VillainsContext";
describe("VillainList", () => {
it("no villains should not display a list nor search bar", () => {
cy.wrappedMount(
<VillainList handleDeleteVillain={cy.stub().as("handleDeleteVillain")} />
);
cy.getByCy("villain-list").should("exist");
cy.getByCyLike("villain-list-item").should("not.exist");
cy.getByCy("search").should("not.exist");
});
context("with villains in the list", () => {
beforeEach(() => {
cy.wrappedMount(
<VillainsContext.Provider value={villains}>
<VillainList
handleDeleteVillain={cy.stub().as("handleDeleteVillain")}
/>
</VillainsContext.Provider>
);
});
it("should render the villain layout", () => {
cy.getByCyLike("villain-list-item").should(
"have.length",
villains.length
);
cy.getByCy("card-content");
cy.contains(villains[0].name);
cy.contains(villains[0].description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
it("should search and filter villain by name and description", () => {
cy.getByCy("search").type(villains[0].name);
cy.getByCyLike("villain-list-item")
.should("have.length", 1)
.contains(villains[0].name);
cy.getByCy("search").clear().type(villains[2].description);
cy.getByCyLike("villain-list-item")
.should("have.length", 1)
.contains(villains[2].description);
});
it("should handle delete", () => {
cy.getByCy("delete-button").first().click();
cy.get("@handleDeleteVillain").should("have.been.called");
});
it("should handle edit", () => {
cy.getByCy("edit-button").first().click();
cy.location("pathname").should(
"eq",
"/villains/edit-villain/" + villains[0].id
);
});
});
});
RTL testini, bağlam sağlayıcıyı kullanarak renderlarken de güncelleyin.
VillainsContext'te oluşturulan bağlam, hakem görevi görüyor. Villains.tsx bağlamı, villains durumunu alt bileşeni VillainList'e iletmek için kullanır. VillainList durumunu VillainsContext'ten alır. VillainsContext yerine, içe aktarmaları azaltacak bir kanca kullanabiliriz.
src/villains/VillainsContext.ts dosyasını kaldırın ve bunun yerine src/hooks/useVillainsContext.ts adlı bir kanca oluşturun. Kancanın ilk yarısı VillainsContext ile aynıdır. Durumu ayarlamak için bir ayarlayıcı ekleriz, böylece durumu alan bileşenler de onu ayarlama yeteneği kazanır. Ayrıca kancanın işlevselliğiyle ilgili durumu ve etkileri kancanın içinde yönetir ve yalnızca bileşenlerin ihtiyaç duyduğu değer(ler)i döndürürüz.
// src/hooks/useVillainsContext.ts
import { createContext, useContext, SetStateAction, Dispatch } from "react";
import { Villain } from "models/Villain";
// Context api lets us pass a value deep into the component tree
// without explicitly threading it through every component (2nd tier state management)
const VillainsContext = createContext<Villain[]>([]);
// to be used as VillainsContext.Provider,
// takes a prop as `value`, which is the context/data/state to share
export default VillainsContext;
const VillainsSetContext = createContext<Dispatch<
SetStateAction<Villain[] | null>
> | null>(null);
// Manage state and effects related to a hook’s functionality
// within the hook and return only the value(s) that components need
export function useVillainsContext() {
const villains = useContext(VillainsContext);
const setVillains = useContext(VillainsSetContext);
return [villains, setVillains] as const;
}
Villains.tsx'deki tek değişiklik, VillainsContext'in içe aktarma konumudur.
Benzer şekilde, VillainsList bileşen testinde yalnızca içe aktarma değişir. useVillainsContext adlı kancayı kullanıyoruz. Aynı durum VillainList.test.tsx için de geçerlidir.
// src/villains/VillainList.cy.tsx
import VillainList from "./VillainList";
import "../styles.scss";
import villains from "../../cypress/fixtures/villains.json";
import VillainsContext from "hooks/useVillainsContext";
describe("VillainList", () => {
it("no villains should not display a list nor search bar", () => {
cy.wrappedMount(
<VillainList handleDeleteVillain={cy.stub().as("handleDeleteVillain")} />
);
cy.getByCy("villain-list").should("exist");
cy.getByCyLike("villain-list-item").should("not.exist");
cy.getByCy("search").should("not.exist");
});
context("with villains in the list", () => {
beforeEach(() => {
cy.wrappedMount(
<VillainsContext.Provider value={villains}>
<VillainList
handleDeleteVillain={cy.stub().as("handleDeleteVillain")}
/>
</VillainsContext.Provider>
);
});
it("should render the villain layout", () => {
cy.getByCyLike("villain-list-item").should(
"have.length",
villains.length
);
cy.getByCy("card-content");
cy.contains(villains[0].name);
cy.contains(villains[0].description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
it("should search and filter villain by name and description", () => {
cy.getByCy("search").type(villains[0].name);
cy.getByCyLike("villain-list-item")
.should("have.length", 1)
.contains(villains[0].name);
cy.getByCy("search").clear().type(villains[2].description);
cy.getByCyLike("villain-list-item")
.should("have.length", 1)
.contains(villains[2].description);
});
it("should handle delete", () => {
cy.getByCy("delete-button").first().click();
cy.get("@handleDeleteVillain").should("have.been.called");
});
it("should handle edit", () => {
cy.getByCy("edit-button").first().click();
cy.location("pathname").should(
"eq",
"/villains/edit-villain/" + villains[0].id
);
});
});
});
VillainList.tsx'de, Villains.tsx'de olduğu gibi içe aktarma değişir. Ayrıca, useContext'i içe aktarmaya gerek kalmaz. Şimdi villains'i de yapılandırıyoruz; const [villains] = useVillainsContext(). Gerekirse, bağlamı ayarlamak için kancadan ayarlayıcıyı da alabiliriz.
// src/villains/VillainList.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 { useVillainsContext } from "hooks/useVillainsContext";
import { Villain } from "models/Villain";
type VillainListProps = {
handleDeleteVillain: (
villain: Villain
) => (e: MouseEvent<HTMLButtonElement>) => void;
};
export default function VillainList({ handleDeleteVillain }: VillainListProps) {
const [villains] = useVillainsContext();
const deferredVillains = useDeferredValue(villains);
const isStale = deferredVillains !== villains;
const [filteredVillains, setFilteredVillains] = useState(deferredVillains);
const navigate = useNavigate();
const [isPending, startTransition] = useTransition();
// needed to refresh the list after deleting a villain
useEffect(() => setFilteredVillains(deferredVillains), [deferredVillains]);
// currying: the outer fn takes our custom arg and returns a fn that takes the event
const handleSelectVillain = (villainId: string) => () => {
const villain = deferredVillains.find((h: Villain) => h.id === villainId);
navigate(
`/villains/edit-villain/${villain?.id}?name=${villain?.name}&description=${villain?.description}`
);
};
type VillainProperty =
| Villain["name"]
| Villain["description"]
| Villain["id"];
/** returns a boolean whether the villain properties exist in the search field */
const searchExists = (searchProperty: VillainProperty, 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: Villain[]) =>
[...data].filter((item: Villain) =>
Object.values(item).find((property: VillainProperty) =>
searchExists(property, searchField)
)
);
/** filters the villains data to see if the any of the properties exist in the list */
const handleSearch =
(data: Villain[]) => (event: ChangeEvent<HTMLInputElement>) => {
const searchField = event.target.value;
return startTransition(() =>
setFilteredVillains(searchProperties(searchField, data))
);
};
return (
<div
style={{
opacity: isPending ? 0.5 : 1,
color: isStale ? "dimgray" : "black",
}}
>
{deferredVillains.length > 0 && (
<div className="card-content">
<span>Search </span>
<input data-cy="search" onChange={handleSearch(deferredVillains)} />
</div>
)}
<ul data-cy="villain-list" className="list">
{filteredVillains.map((villain, index) => (
<li data-cy={`villain-list-item-${index}`} key={villain.id}>
<div className="card">
<CardContent
name={villain.name}
description={villain.description}
/>
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteVillain(villain)}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectVillain(villain.id)}
/>
</footer>
</div>
</li>
))}
</ul>
</div>
);
}
Özet ve Çıkarımlar
Context API, değeri her bileşenden açıkça geçirmeye gerek kalmadan bileşen ağacının derinliklerine geçirmemizi sağlar.
Özel bir kanca kullanın, durumu ve etkileri kancanın içinde yönetin ve bileşenlerin ihtiyaç duyduğu değerleri döndürün, bu değer ve setValue olabilir.
cy.intercept ve MSW ile, bir ağ isteğinin dışarı çıktığını kontrol ettik ve işlemin bir kancanın çağrılmasına neden olduğunu kontrol ettik. Sonuç olarak, kancaların değiştirilmesinin testler üzerinde veya işlevsellik üzerinde hiçbir etkisi olmadı. İşte bu yüzden biraz daha yüksek bir soyutlama düzeyinde test etmek istiyoruz ve uygulamanın sonuçlarını uygulama ayrıntılarına kıyasla doğrulamak istiyoruz.