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?
|
||||
* 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,
|
||||
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;
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 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,
|
||||
|
|
44
src/util.js
44
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 }) {
|
|||
</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