Item search frontend + backend!
This commit is contained in:
parent
66b0314990
commit
eac6b308cd
9 changed files with 277 additions and 146 deletions
|
@ -1 +1,3 @@
|
||||||
* Use accessible click targets for item lists! Honestly, can they be checkboxes?
|
* Use accessible click targets for item lists! Honestly, can they be checkboxes?
|
||||||
|
* Pagination for search queries, right now we LIMIT 30
|
||||||
|
* Search needs to restrict by fit!
|
||||||
|
|
103
src/SearchPanel.js
Normal file
103
src/SearchPanel.js
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import React from "react";
|
||||||
|
import gql from "graphql-tag";
|
||||||
|
import { Box, Text } from "@chakra-ui/core";
|
||||||
|
import { useQuery } from "@apollo/react-hooks";
|
||||||
|
|
||||||
|
import { Delay, Heading1, useDebounce } from "./util";
|
||||||
|
import ItemList, { ItemListSkeleton } from "./ItemList";
|
||||||
|
import { itemAppearanceFragment } from "./OutfitPreview";
|
||||||
|
|
||||||
|
function SearchPanel({ query, outfitState, dispatchToOutfit }) {
|
||||||
|
return (
|
||||||
|
<Box color="green.800">
|
||||||
|
<Heading1 mb="6">Searching for "{query}"</Heading1>
|
||||||
|
<SearchResults
|
||||||
|
query={query}
|
||||||
|
outfitState={outfitState}
|
||||||
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResults({ query, outfitState, dispatchToOutfit }) {
|
||||||
|
const { wornItemIds, speciesId, colorId } = outfitState;
|
||||||
|
|
||||||
|
const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true });
|
||||||
|
|
||||||
|
const { loading, error, data, variables } = useQuery(
|
||||||
|
gql`
|
||||||
|
query($query: String!, $speciesId: ID!, $colorId: ID!) {
|
||||||
|
itemSearch(query: $query) {
|
||||||
|
# TODO: De-dupe this from useOutfitState?
|
||||||
|
id
|
||||||
|
name
|
||||||
|
thumbnailUrl
|
||||||
|
|
||||||
|
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||||
|
# This enables us to quickly show the item when the user clicks it!
|
||||||
|
...AppearanceForOutfitPreview
|
||||||
|
|
||||||
|
# This is used to group items by zone, and to detect conflicts when
|
||||||
|
# wearing a new item.
|
||||||
|
layers {
|
||||||
|
zone {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${itemAppearanceFragment}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
variables: { query: debouncedQuery, speciesId, colorId },
|
||||||
|
skip: debouncedQuery === null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading || variables.query !== query) {
|
||||||
|
return (
|
||||||
|
<Delay ms={500}>
|
||||||
|
<ItemListSkeleton />
|
||||||
|
</Delay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Text color="green.500">
|
||||||
|
We hit an error trying to load your search results{" "}
|
||||||
|
<span role="img" aria-label="(sweat emoji)">
|
||||||
|
😓
|
||||||
|
</span>{" "}
|
||||||
|
Try again?
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = data.itemSearch;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text color="green.500">
|
||||||
|
We couldn't find any matching items{" "}
|
||||||
|
<span role="img" aria-label="(thinking emoji)">
|
||||||
|
🤔
|
||||||
|
</span>{" "}
|
||||||
|
Try again?
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemList
|
||||||
|
items={items}
|
||||||
|
wornItemIds={wornItemIds}
|
||||||
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchPanel;
|
|
@ -5,7 +5,6 @@ import {
|
||||||
EditablePreview,
|
EditablePreview,
|
||||||
EditableInput,
|
EditableInput,
|
||||||
Grid,
|
Grid,
|
||||||
Heading,
|
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
@ -15,15 +14,14 @@ import {
|
||||||
PseudoBox,
|
PseudoBox,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
|
||||||
useToast,
|
useToast,
|
||||||
} from "@chakra-ui/core";
|
} from "@chakra-ui/core";
|
||||||
|
|
||||||
|
import { Delay, Heading1, Heading2 } from "./util";
|
||||||
import ItemList, { ItemListSkeleton } from "./ItemList";
|
import ItemList, { ItemListSkeleton } from "./ItemList";
|
||||||
import useItemData from "./useItemData";
|
|
||||||
import useOutfitState from "./useOutfitState.js";
|
|
||||||
import OutfitPreview from "./OutfitPreview";
|
import OutfitPreview from "./OutfitPreview";
|
||||||
import { Delay } from "./util";
|
import SearchPanel from "./SearchPanel";
|
||||||
|
import useOutfitState from "./useOutfitState.js";
|
||||||
|
|
||||||
function WardrobePage() {
|
function WardrobePage() {
|
||||||
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
|
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
|
||||||
|
@ -32,12 +30,13 @@ function WardrobePage() {
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
console.log(error);
|
||||||
toast({
|
toast({
|
||||||
title: "We couldn't load this outfit 😖",
|
title: "We couldn't load this outfit 😖",
|
||||||
description: "Please reload the page to try again. Sorry!",
|
description: "Please reload the page to try again. Sorry!",
|
||||||
status: "error",
|
status: "error",
|
||||||
isClosable: true,
|
isClosable: true,
|
||||||
duration: Infinity,
|
duration: 999999999,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [error, toast]);
|
}, [error, toast]);
|
||||||
|
@ -130,78 +129,6 @@ function SearchToolbar({ query, onChange }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchPanel({ query, outfitState, dispatchToOutfit }) {
|
|
||||||
const { allItemIds, wornItemIds, speciesId, colorId } = outfitState;
|
|
||||||
const { loading, error, itemsById } = useItemData(
|
|
||||||
allItemIds,
|
|
||||||
speciesId,
|
|
||||||
colorId
|
|
||||||
);
|
|
||||||
|
|
||||||
const normalize = (s) => s.toLowerCase();
|
|
||||||
const results = Object.values(itemsById).filter((item) =>
|
|
||||||
normalize(item.name).includes(normalize(query))
|
|
||||||
);
|
|
||||||
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box color="green.800">
|
|
||||||
<Heading1 mb="6">Searching for "{query}"</Heading1>
|
|
||||||
<SearchResults
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
results={results}
|
|
||||||
wornItemIds={wornItemIds}
|
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchResults({
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
results,
|
|
||||||
wornItemIds,
|
|
||||||
dispatchToOutfit,
|
|
||||||
}) {
|
|
||||||
if (loading) {
|
|
||||||
return <ItemListSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Text color="green.500">
|
|
||||||
We hit an error trying to load your search results{" "}
|
|
||||||
<span role="img" aria-label="(sweat emoji)">
|
|
||||||
😓
|
|
||||||
</span>{" "}
|
|
||||||
Try again?
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
return (
|
|
||||||
<Text color="green.500">
|
|
||||||
We couldn't find any matching items{" "}
|
|
||||||
<span role="img" aria-label="(thinking emoji)">
|
|
||||||
🤔
|
|
||||||
</span>{" "}
|
|
||||||
Try again?
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ItemList
|
|
||||||
items={results}
|
|
||||||
wornItemIds={wornItemIds}
|
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
|
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
|
||||||
const { zonesAndItems, wornItemIds } = outfitState;
|
const { zonesAndItems, wornItemIds } = outfitState;
|
||||||
|
|
||||||
|
@ -279,20 +206,4 @@ function OutfitNameEditButton({ onRequestEdit }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Heading1({ children, ...props }) {
|
|
||||||
return (
|
|
||||||
<Heading fontFamily="Delicious" fontWeight="800" size="2xl" {...props}>
|
|
||||||
{children}
|
|
||||||
</Heading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Heading2({ children, ...props }) {
|
|
||||||
return (
|
|
||||||
<Heading size="xl" color="green.800" fontFamily="Delicious" {...props}>
|
|
||||||
{children}
|
|
||||||
</Heading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WardrobePage;
|
export default WardrobePage;
|
||||||
|
|
|
@ -36,6 +36,7 @@ const typeDefs = gql`
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
items(ids: [ID!]!): [Item!]!
|
items(ids: [ID!]!): [Item!]!
|
||||||
|
itemSearch(query: String!): [Item!]!
|
||||||
petAppearance(speciesId: ID!, colorId: ID!): Appearance
|
petAppearance(speciesId: ID!, colorId: ID!): Appearance
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -107,6 +108,10 @@ const resolvers = {
|
||||||
const items = await itemLoader.loadMany(ids);
|
const items = await itemLoader.loadMany(ids);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
itemSearch: async (_, { query }, { itemSearchLoader }) => {
|
||||||
|
const items = await itemSearchLoader.load(query);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
petAppearance: async (
|
petAppearance: async (
|
||||||
_,
|
_,
|
||||||
{ speciesId, colorId },
|
{ speciesId, colorId },
|
||||||
|
|
|
@ -370,6 +370,63 @@ describe("PetAppearance", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Search", () => {
|
||||||
|
it("loads Zafara Agent items", async () => {
|
||||||
|
const res = await query({
|
||||||
|
query: gql`
|
||||||
|
query {
|
||||||
|
itemSearch(query: "Zafara Agent") {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res).toHaveNoErrors();
|
||||||
|
expect(res.data).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"itemSearch": Array [
|
||||||
|
Object {
|
||||||
|
"id": "38913",
|
||||||
|
"name": "Zafara Agent Gloves",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"id": "38911",
|
||||||
|
"name": "Zafara Agent Hood",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"id": "38912",
|
||||||
|
"name": "Zafara Agent Robe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(queryFn.mock.calls).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Array [
|
||||||
|
"SELECT items.* FROM items
|
||||||
|
INNER JOIN item_translations t ON t.item_id = items.id
|
||||||
|
WHERE t.name LIKE ? AND locale=\\"en\\"
|
||||||
|
ORDER BY t.name
|
||||||
|
LIMIT 30",
|
||||||
|
Array [
|
||||||
|
"%Zafara Agent%",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
Array [
|
||||||
|
"SELECT * FROM item_translations WHERE item_id IN (?,?,?) AND locale = \\"en\\"",
|
||||||
|
Array [
|
||||||
|
"38913",
|
||||||
|
"38911",
|
||||||
|
"38912",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect.extend({
|
expect.extend({
|
||||||
toHaveNoErrors(res) {
|
toHaveNoErrors(res) {
|
||||||
if (res.errors) {
|
if (res.errors) {
|
||||||
|
|
|
@ -35,6 +35,31 @@ const buildItemTranslationLoader = (db) =>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const buildItemSearchLoader = (db) =>
|
||||||
|
new DataLoader(async (queries) => {
|
||||||
|
// This isn't actually optimized as a batch query, we're just using a
|
||||||
|
// DataLoader API consistency with our other loaders!
|
||||||
|
const queryPromises = queries.map(async (query) => {
|
||||||
|
const queryForMysql = "%" + query.replace(/_%/g, "\\$0") + "%";
|
||||||
|
const [rows, _] = await db.execute(
|
||||||
|
`SELECT items.* FROM items
|
||||||
|
INNER JOIN item_translations t ON t.item_id = items.id
|
||||||
|
WHERE t.name LIKE ? AND locale="en"
|
||||||
|
ORDER BY t.name
|
||||||
|
LIMIT 30`,
|
||||||
|
[queryForMysql]
|
||||||
|
);
|
||||||
|
|
||||||
|
const entities = rows.map(normalizeRow);
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
});
|
||||||
|
|
||||||
|
const responses = await Promise.all(queryPromises);
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
});
|
||||||
|
|
||||||
const buildPetTypeLoader = (db) =>
|
const buildPetTypeLoader = (db) =>
|
||||||
new DataLoader(async (speciesAndColorPairs) => {
|
new DataLoader(async (speciesAndColorPairs) => {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
@ -174,6 +199,7 @@ function buildLoaders(db) {
|
||||||
return {
|
return {
|
||||||
itemLoader: buildItemsLoader(db),
|
itemLoader: buildItemsLoader(db),
|
||||||
itemTranslationLoader: buildItemTranslationLoader(db),
|
itemTranslationLoader: buildItemTranslationLoader(db),
|
||||||
|
itemSearchLoader: buildItemSearchLoader(db),
|
||||||
petTypeLoader: buildPetTypeLoader(db),
|
petTypeLoader: buildPetTypeLoader(db),
|
||||||
itemSwfAssetLoader: buildItemSwfAssetLoader(db),
|
itemSwfAssetLoader: buildItemSwfAssetLoader(db),
|
||||||
petSwfAssetLoader: buildPetSwfAssetLoader(db),
|
petSwfAssetLoader: buildPetSwfAssetLoader(db),
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import gql from "graphql-tag";
|
|
||||||
import { useQuery } from "@apollo/react-hooks";
|
|
||||||
|
|
||||||
import { itemAppearanceFragment } from "./OutfitPreview";
|
|
||||||
|
|
||||||
function useItemData(itemIds, speciesId, colorId) {
|
|
||||||
const { loading, error, data } = useQuery(
|
|
||||||
gql`
|
|
||||||
query($itemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) {
|
|
||||||
items(ids: $itemIds) {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
thumbnailUrl
|
|
||||||
|
|
||||||
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
|
||||||
# This enables us to quickly show the item when the user clicks it!
|
|
||||||
...AppearanceForOutfitPreview
|
|
||||||
|
|
||||||
# This is used to group items by zone, and to detect conflicts when
|
|
||||||
# wearing a new item.
|
|
||||||
layers {
|
|
||||||
zone {
|
|
||||||
id
|
|
||||||
label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
${itemAppearanceFragment}
|
|
||||||
`,
|
|
||||||
{ variables: { itemIds, speciesId, colorId } }
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = (data && data.items) || [];
|
|
||||||
const itemsById = {};
|
|
||||||
for (const item of items) {
|
|
||||||
itemsById[item.id] = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { loading, error, itemsById };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useItemData;
|
|
|
@ -1,9 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
import produce, { enableMapSet } from "immer";
|
import produce, { enableMapSet } from "immer";
|
||||||
import { useApolloClient } from "@apollo/react-hooks";
|
import { useQuery, useApolloClient } from "@apollo/react-hooks";
|
||||||
|
|
||||||
import useItemData from "./useItemData";
|
import { itemAppearanceFragment } from "./OutfitPreview";
|
||||||
|
|
||||||
enableMapSet();
|
enableMapSet();
|
||||||
|
|
||||||
|
@ -36,12 +36,41 @@ function useOutfitState() {
|
||||||
const closetedItemIds = Array.from(state.closetedItemIds);
|
const closetedItemIds = Array.from(state.closetedItemIds);
|
||||||
|
|
||||||
const allItemIds = [...state.wornItemIds, ...state.closetedItemIds];
|
const allItemIds = [...state.wornItemIds, ...state.closetedItemIds];
|
||||||
const { loading, error, itemsById } = useItemData(
|
const { loading, error, data } = useQuery(
|
||||||
allItemIds,
|
gql`
|
||||||
speciesId,
|
query($allItemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) {
|
||||||
colorId
|
items(ids: $allItemIds) {
|
||||||
|
# TODO: De-dupe this from SearchPanel?
|
||||||
|
id
|
||||||
|
name
|
||||||
|
thumbnailUrl
|
||||||
|
|
||||||
|
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||||
|
# This enables us to quickly show the item when the user clicks it!
|
||||||
|
...AppearanceForOutfitPreview
|
||||||
|
|
||||||
|
# This is used to group items by zone, and to detect conflicts when
|
||||||
|
# wearing a new item.
|
||||||
|
layers {
|
||||||
|
zone {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${itemAppearanceFragment}
|
||||||
|
`,
|
||||||
|
{ variables: { allItemIds, speciesId, colorId } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const items = (data && data.items) || [];
|
||||||
|
const itemsById = {};
|
||||||
|
for (const item of items) {
|
||||||
|
itemsById[item.id] = item;
|
||||||
|
}
|
||||||
|
|
||||||
const zonesAndItems = getZonesAndItems(
|
const zonesAndItems = getZonesAndItems(
|
||||||
itemsById,
|
itemsById,
|
||||||
wornItemIds,
|
wornItemIds,
|
||||||
|
|
44
src/util.js
44
src/util.js
|
@ -1,5 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box } from "@chakra-ui/core";
|
import { Box, Heading } from "@chakra-ui/core";
|
||||||
|
|
||||||
export function Delay({ children, ms = 300 }) {
|
export function Delay({ children, ms = 300 }) {
|
||||||
const [isVisible, setIsVisible] = React.useState(false);
|
const [isVisible, setIsVisible] = React.useState(false);
|
||||||
|
@ -15,3 +15,45 @@ export function Delay({ children, ms = 300 }) {
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Heading1({ children, ...props }) {
|
||||||
|
return (
|
||||||
|
<Heading fontFamily="Delicious" fontWeight="800" size="2xl" {...props}>
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Heading2({ children, ...props }) {
|
||||||
|
return (
|
||||||
|
<Heading size="xl" color="green.800" fontFamily="Delicious" {...props}>
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// From https://usehooks.com/useDebounce/
|
||||||
|
export function useDebounce(value, delay, { waitForFirstPause = false } = {}) {
|
||||||
|
// State and setters for debounced value
|
||||||
|
const initialValue = waitForFirstPause ? null : value;
|
||||||
|
const [debouncedValue, setDebouncedValue] = React.useState(initialValue);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => {
|
||||||
|
// Update debounced value after delay
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||||
|
// This is how we prevent debounced value from updating if value is changed ...
|
||||||
|
// .. within the delay period. Timeout gets cleared and restarted.
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[value, delay] // Only re-call effect if value or delay changes
|
||||||
|
);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue