diff --git a/dev-todos.txt b/dev-todos.txt index 1e5bd49..5e18a98 100644 --- a/dev-todos.txt +++ b/dev-todos.txt @@ -1 +1,3 @@ -* Use accessible click targets for item lists! Honestly, can they be checkboxes? \ No newline at end of file +* 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! diff --git a/src/SearchPanel.js b/src/SearchPanel.js new file mode 100644 index 0000000..9a36815 --- /dev/null +++ b/src/SearchPanel.js @@ -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 ( + + Searching for "{query}" + + + ); +} + +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 ( + + + + ); + } + + if (error) { + return ( + + We hit an error trying to load your search results{" "} + + 😓 + {" "} + Try again? + + ); + } + + const items = data.itemSearch; + + if (items.length === 0) { + return ( + + We couldn't find any matching items{" "} + + 🤔 + {" "} + Try again? + + ); + } + + return ( + + ); +} + +export default SearchPanel; diff --git a/src/WardrobePage.js b/src/WardrobePage.js index 6c0ade6..2689d8f 100644 --- a/src/WardrobePage.js +++ b/src/WardrobePage.js @@ -5,7 +5,6 @@ import { EditablePreview, EditableInput, Grid, - Heading, Icon, IconButton, Input, @@ -15,15 +14,14 @@ import { PseudoBox, Skeleton, Stack, - Text, useToast, } from "@chakra-ui/core"; +import { Delay, Heading1, Heading2 } from "./util"; import ItemList, { ItemListSkeleton } from "./ItemList"; -import useItemData from "./useItemData"; -import useOutfitState from "./useOutfitState.js"; import OutfitPreview from "./OutfitPreview"; -import { Delay } from "./util"; +import SearchPanel from "./SearchPanel"; +import useOutfitState from "./useOutfitState.js"; function WardrobePage() { const { loading, error, outfitState, dispatchToOutfit } = useOutfitState(); @@ -32,12 +30,13 @@ function WardrobePage() { React.useEffect(() => { if (error) { + console.log(error); toast({ title: "We couldn't load this outfit 😖", description: "Please reload the page to try again. Sorry!", status: "error", isClosable: true, - duration: Infinity, + duration: 999999999, }); } }, [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 ( - - Searching for "{query}" - - - ); -} - -function SearchResults({ - loading, - error, - results, - wornItemIds, - dispatchToOutfit, -}) { - if (loading) { - return ; - } - - if (error) { - return ( - - We hit an error trying to load your search results{" "} - - 😓 - {" "} - Try again? - - ); - } - - if (results.length === 0) { - return ( - - We couldn't find any matching items{" "} - - 🤔 - {" "} - Try again? - - ); - } - - return ( - - ); -} - function ItemsPanel({ outfitState, loading, dispatchToOutfit }) { const { zonesAndItems, wornItemIds } = outfitState; @@ -279,20 +206,4 @@ function OutfitNameEditButton({ onRequestEdit }) { ); } -function Heading1({ children, ...props }) { - return ( - - {children} - - ); -} - -function Heading2({ children, ...props }) { - return ( - - {children} - - ); -} - export default WardrobePage; diff --git a/src/server/index.js b/src/server/index.js index e54856f..a837586 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -36,6 +36,7 @@ const typeDefs = gql` type Query { items(ids: [ID!]!): [Item!]! + itemSearch(query: String!): [Item!]! petAppearance(speciesId: ID!, colorId: ID!): Appearance } `; @@ -107,6 +108,10 @@ const resolvers = { const items = await itemLoader.loadMany(ids); return items; }, + itemSearch: async (_, { query }, { itemSearchLoader }) => { + const items = await itemSearchLoader.load(query); + return items; + }, petAppearance: async ( _, { speciesId, colorId }, diff --git a/src/server/index.test.js b/src/server/index.test.js index cbf9cb6..63d7cd4 100644 --- a/src/server/index.test.js +++ b/src/server/index.test.js @@ -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({ toHaveNoErrors(res) { if (res.errors) { diff --git a/src/server/loaders.js b/src/server/loaders.js index 55c83fe..c3230ec 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -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) => new DataLoader(async (speciesAndColorPairs) => { const conditions = []; @@ -174,6 +199,7 @@ function buildLoaders(db) { return { itemLoader: buildItemsLoader(db), itemTranslationLoader: buildItemTranslationLoader(db), + itemSearchLoader: buildItemSearchLoader(db), petTypeLoader: buildPetTypeLoader(db), itemSwfAssetLoader: buildItemSwfAssetLoader(db), petSwfAssetLoader: buildPetSwfAssetLoader(db), diff --git a/src/useItemData.js b/src/useItemData.js deleted file mode 100644 index a2dbdff..0000000 --- a/src/useItemData.js +++ /dev/null @@ -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; diff --git a/src/useOutfitState.js b/src/useOutfitState.js index 8ffdf6f..406b8ce 100644 --- a/src/useOutfitState.js +++ b/src/useOutfitState.js @@ -1,9 +1,9 @@ import React from "react"; import gql from "graphql-tag"; 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(); @@ -36,12 +36,41 @@ function useOutfitState() { const closetedItemIds = Array.from(state.closetedItemIds); const allItemIds = [...state.wornItemIds, ...state.closetedItemIds]; - const { loading, error, itemsById } = useItemData( - allItemIds, - speciesId, - colorId + const { loading, error, data } = useQuery( + gql` + query($allItemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) { + 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( itemsById, wornItemIds, diff --git a/src/util.js b/src/util.js index b12f694..a5a61cb 100644 --- a/src/util.js +++ b/src/util.js @@ -1,5 +1,5 @@ import React from "react"; -import { Box } from "@chakra-ui/core"; +import { Box, Heading } from "@chakra-ui/core"; export function Delay({ children, ms = 300 }) { const [isVisible, setIsVisible] = React.useState(false); @@ -15,3 +15,45 @@ export function Delay({ children, ms = 300 }) { ); } + +export function Heading1({ children, ...props }) { + return ( + + {children} + + ); +} + +export function Heading2({ children, ...props }) { + return ( + + {children} + + ); +} + +// 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; +}