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