Item search frontend + backend!

This commit is contained in:
Matt Dunn-Rankin 2020-04-24 21:17:03 -07:00
parent 66b0314990
commit eac6b308cd
9 changed files with 277 additions and 146 deletions

View file

@ -1 +1,3 @@
* 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
View 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;

View file

@ -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 (
<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 }) {
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;

View file

@ -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 },

View file

@ -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) {

View file

@ -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),

View file

@ -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;

View file

@ -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,

View file

@ -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 }) {
</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;
}