Add an /items/search page, and search box on Home

Woo, it's looking pretty good, I think!

I didn't bother with pagination yet, since I feel like that'll be a bit of a design and eng lift unto itself... but I figured people would appreciate the ability to look up individual items, even if the rest isn't ready yet 😅
This commit is contained in:
Emi Matchu 2021-01-18 15:56:24 -08:00
parent 9e5dfd1c84
commit b39914976b
9 changed files with 476 additions and 84 deletions

View file

@ -20,15 +20,18 @@ import WardrobePageLayout from "./WardrobePage/WardrobePageLayout";
// Loading the page will often fail after a deploy, because Vercel doesn't keep
// old JS chunks on the CDN. Recover by reloading!
const tryLoadable = (load) =>
loadable(() =>
load().catch((e) => {
console.error("Error loading page, reloading", e);
window.location.reload();
})
const tryLoadable = (load, options) =>
loadable(
() =>
load().catch((e) => {
console.error("Error loading page, reloading", e);
window.location.reload();
}),
options
);
const HomePage = tryLoadable(() => import("./HomePage"));
const ItemSearchPage = tryLoadable(() => import("./ItemSearchPage"));
const ItemPage = tryLoadable(() => import("./ItemPage"));
const ItemTradesOfferingPage = tryLoadable(() =>
import("./ItemTradesPage").then((m) => m.ItemTradesOfferingPage)
@ -107,6 +110,11 @@ function App() {
<ChakraProvider theme={theme}>
<CSSReset />
<Switch>
<Route path="/items/search/:query?">
<PageLayout>
<ItemSearchPage />
</PageLayout>
</Route>
<Route path="/items/:itemId/trades/offering">
<PageLayout>
<ItemTradesOfferingPage />

View file

@ -6,13 +6,18 @@ import {
Button,
Flex,
HStack,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Textarea,
useColorModeValue,
useTheme,
useToast,
VStack,
} from "@chakra-ui/react";
import { ArrowForwardIcon, SearchIcon } from "@chakra-ui/icons";
import { useHistory, useLocation } from "react-router-dom";
import { useLazyQuery, useQuery } from "@apollo/client";
@ -281,12 +286,67 @@ function SubmitPetForm() {
function NewItemsSection() {
return (
<Box width="100%">
<Heading2 textAlign="left">Latest items</Heading2>
<Flex align="center" wrap="wrap">
<Heading2 flex="0 0 auto" marginRight="2" textAlign="left">
Latest items
</Heading2>
<Box flex="0 0 auto" marginLeft="auto" width="48">
<ItemsSearchField />
</Box>
</Flex>
<NewItemsSectionContent />
</Box>
);
}
function ItemsSearchField() {
const [query, setQuery] = React.useState("");
const { brightBackground } = useCommonStyles();
const history = useHistory();
return (
<form
onSubmit={() => {
if (query) {
history.push(`/items/search/${encodeURIComponent(query)}`);
}
}}
>
<InputGroup
size="sm"
backgroundColor={query ? brightBackground : "transparent"}
_focusWithin={{ backgroundColor: brightBackground }}
>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search all items…"
borderRadius="full"
/>
<InputRightElement>
<IconButton
type="submit"
variant="ghost"
icon={<ArrowForwardIcon />}
aria-label="Search"
minWidth="1.5rem"
minHeight="1.5rem"
width="1.5rem"
height="1.5rem"
borderRadius="full"
opacity={query ? 1 : 0}
transition="opacity 0.2s"
aria-hidden={query ? "false" : "true"}
/>
</InputRightElement>
</InputGroup>
</form>
);
}
function NewItemsSectionContent() {
const { loading, error, data } = useQuery(
gql`

287
src/app/ItemSearchPage.js Normal file
View file

@ -0,0 +1,287 @@
import React from "react";
import { Box, Flex, Wrap, WrapItem } from "@chakra-ui/react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import { useHistory, useLocation, useParams } from "react-router-dom";
import SearchToolbar, {
emptySearchQuery,
searchQueryIsEmpty,
} from "./WardrobePage/SearchToolbar";
import SquareItemCard, {
SquareItemCardSkeleton,
} from "./components/SquareItemCard";
import WIPCallout from "./components/WIPCallout";
import { Delay, ErrorMessage, useCommonStyles, useDebounce } from "./util";
function ItemSearchPage() {
const [query, setQuery] = useSearchQueryInUrl();
const { brightBackground } = useCommonStyles();
return (
<Box>
<SearchToolbar
query={query}
onChange={setQuery}
showItemsLabel
background={brightBackground}
boxShadow="md"
/>
<Box height="6" />
<ItemSearchPageResults query={query} />
</Box>
);
}
/**
* useSearchQueryInUrl provides an API like useState, but stores the search
* query in the URL!
*/
function useSearchQueryInUrl() {
const history = useHistory();
const { query: value } = useParams();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const query = {
value: value || "",
filterToZoneLabel: searchParams.get("zone") || null,
filterToItemKind: searchParams.get("kind") || null,
};
const setQuery = React.useCallback(
(newQuery) => {
let url = `/items/search`;
if (newQuery.value) {
url += "/" + encodeURIComponent(newQuery.value);
}
const newParams = new URLSearchParams();
if (newQuery.filterToItemKind) {
newParams.append("kind", newQuery.filterToItemKind);
}
if (newQuery.filterToZoneLabel) {
newParams.append("zone", newQuery.filterToZoneLabel);
}
const search = newParams.toString();
if (search) {
url += "?" + search;
}
history.replace(url);
},
[history]
);
return [query, setQuery];
}
function ItemSearchPageResults({ query: latestQuery }) {
// NOTE: Some of this is copied from SearchPanel... but all of this is messy
// enough that I'm not comfy code-sharing yet, esp since I feel like
// SearchPanel pagination is a bit of a mess and will need refactoring.
// We debounce the search query, so that we don't resend a new query whenever
// the user types anything.
const query = useDebounce(latestQuery, 300, {
waitForFirstPause: true,
initialValue: emptySearchQuery,
});
// We'll skip all this if the query is empty. We also check the latest query
// for this, without waiting for the debounce, in order to get fast feedback
// when clearing the query. But we _do_ still check the debounced query too,
// which gives us _slow_ feedback when moving from empty to _non_-empty.
const skipSearchResults =
searchQueryIsEmpty(query) || searchQueryIsEmpty(latestQuery);
// NOTE: This query should always load ~instantly, from the client cache.
const { data: zoneData } = useQuery(gql`
query SearchPanelZones {
allZones {
id
label
}
}
`);
const allZones = zoneData?.allZones || [];
const filterToZones = query.filterToZoneLabel
? allZones.filter((z) => z.label === query.filterToZoneLabel)
: [];
const filterToZoneIds = filterToZones.map((z) => z.id);
const { loading, error, data } = useQuery(
gql`
query ItemSearchPageResults(
$query: String!
$itemKind: ItemKindSearchFilter
$zoneIds: [ID!]!
) {
itemSearch(
query: $query
itemKind: $itemKind
zoneIds: $zoneIds
offset: 0
limit: 30
) {
items {
id
name
thumbnailUrl
}
}
}
`,
{
variables: {
query: query.value,
itemKind: query.filterToItemKind,
zoneIds: filterToZoneIds,
},
skip: skipSearchResults,
}
);
if (skipSearchResults) {
return null;
}
if (loading) {
return (
<Delay>
<ItemSearchPageResultsLoading />
</Delay>
);
}
if (error) {
return (
<ErrorMessage>
Oops, we couldn't load the search results. Check your connection and try
again!
</ErrorMessage>
);
}
return (
<Box>
<Wrap justify="center" spacing="4">
{data.itemSearch.items.map((item) => (
<WrapItem key={item.id}>
<SquareItemCard item={item} />
</WrapItem>
))}
</Wrap>
{data.itemSearch.items.length >= 30 && (
<Flex justify="center">
<WIPCallout
details="I wanted to get this out asap for looking up specific items! Multi-page browsing coming soon 😅"
marginTop="6"
>
We only show the first 30 results for now! 😅
</WIPCallout>
</Flex>
)}
</Box>
);
}
function ItemSearchPageResultsLoading() {
return (
<Wrap justify="center" spacing="4">
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton minHeightNumLines={3} />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
<WrapItem>
<SquareItemCardSkeleton />
</WrapItem>
</Wrap>
);
}
export default ItemSearchPage;

View file

@ -2,11 +2,9 @@ import React from "react";
import { Box, Flex } from "@chakra-ui/react";
import ItemsPanel from "./ItemsPanel";
import SearchToolbar from "./SearchToolbar";
import SearchToolbar, { emptySearchQuery } from "./SearchToolbar";
import SearchPanel from "./SearchPanel";
const emptyQuery = { value: "", filterToZoneLabel: null };
/**
* ItemsAndSearchPanels manages the shared layout and state for:
* - ItemsPanel, which shows the items in the outfit now, and
@ -21,16 +19,11 @@ const emptyQuery = { value: "", filterToZoneLabel: null };
* state and refs.
*/
function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
const [searchQuery, setSearchQuery] = React.useState(emptyQuery);
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
const scrollContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
const onChange = React.useCallback(
(newQuery) => setSearchQuery(newQuery || emptyQuery),
[setSearchQuery]
);
return (
<Flex direction="column" height="100%">
<Box px="5" py="3" boxShadow="sm">
@ -38,7 +31,7 @@ function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
query={searchQuery}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
onChange={onChange}
onChange={setSearchQuery}
/>
</Box>
{searchQuery.value ||

View file

@ -4,6 +4,7 @@ import { Box, Text, VisuallyHidden } from "@chakra-ui/react";
import { useQuery } from "@apollo/client";
import { Delay, useDebounce } from "../util";
import { emptySearchQuery } from "./SearchToolbar";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
@ -217,11 +218,7 @@ function useSearchResults(query, outfitState) {
// the user types anything.
const debouncedQuery = useDebounce(query, 300, {
waitForFirstPause: true,
initialValue: {
value: "",
filterToItemKind: null,
filterToZoneLabel: null,
},
initialValue: emptySearchQuery,
});
// When the query changes, we should update our impression of whether we've

View file

@ -15,6 +15,16 @@ import { CloseIcon, SearchIcon } from "@chakra-ui/icons";
import { ClassNames } from "@emotion/react";
import Autosuggest from "react-autosuggest";
export const emptySearchQuery = {
value: "",
filterToZoneLabel: null,
filterToItemKind: null,
};
export function searchQueryIsEmpty(query) {
return Object.values(query).every((value) => !value);
}
/**
* SearchToolbar is rendered above both the ItemsPanel and the SearchPanel,
* and contains the search field where the user types their query.
@ -29,6 +39,9 @@ function SearchToolbar({
searchQueryRef,
firstSearchResultRef,
onChange,
showItemsLabel = false,
background = null,
boxShadow = null,
}) {
const [suggestions, setSuggestions] = React.useState([]);
@ -110,7 +123,21 @@ function SearchToolbar({
setSuggestions([]);
}, [query.filterToItemKind, query.filterToZoneLabel]);
const queryFilterText = getQueryFilterText(query);
let queryFilterText = getQueryFilterText(query);
if (showItemsLabel) {
queryFilterText = queryFilterText ? (
<>
<Box as="span" fontWeight="600">
Items:
</Box>{" "}
{queryFilterText}
</>
) : (
<Box as="span" fontWeight="600">
Items
</Box>
);
}
const focusBorderColor = useColorModeValue("green.600", "green.400");
@ -137,8 +164,8 @@ function SearchToolbar({
highlightFirstSuggestion={true}
renderSuggestion={renderSuggestion}
renderSuggestionsContainer={renderSuggestionsContainer}
renderInputComponent={(props) => (
<InputGroup>
renderInputComponent={(inputProps) => (
<InputGroup boxShadow={boxShadow} borderRadius="md">
{queryFilterText ? (
<InputLeftAddon>
<SearchIcon color="gray.400" marginRight="3" />
@ -149,7 +176,7 @@ function SearchToolbar({
<SearchIcon color="gray.400" />
</InputLeftElement>
)}
<Input {...props} />
<Input background={background} {...inputProps} />
{(query.value || queryFilterText) && (
<InputRightElement>
<IconButton
@ -158,9 +185,7 @@ function SearchToolbar({
variant="ghost"
colorScheme="green"
aria-label="Clear search"
onClick={() => {
onChange(null);
}}
onClick={() => onChange(emptySearchQuery)}
// Big style hacks here!
height="calc(100% - 2px)"
marginRight="2px"
@ -176,19 +201,6 @@ function SearchToolbar({
value: query.value || "",
ref: searchQueryRef,
minWidth: 0,
borderBottomRadius: suggestions.length > 0 ? "0" : "md",
// HACK: Chakra isn't noticing the InputLeftElement swapping out
// for the InputLeftAddon, so the styles aren't updating...
// Hard override!
className: css`
padding-left: ${queryFilterText ? "1rem" : "2.5rem"} !important;
border-bottom-left-radius: ${queryFilterText
? "0"
: "0.25rem"} !important;
border-top-left-radius: ${queryFilterText
? "0"
: "0.25rem"} !important;
`,
onChange: (e, { newValue, method }) => {
// The Autosuggest tries to change the _entire_ value of the element
// when navigating suggestions, which isn't actually what we want.

View file

@ -2,7 +2,7 @@ import React from "react";
import { Box, Heading, useColorModeValue } from "@chakra-ui/react";
/**
* Delay hides its content and first, then shows it after the given delay.
* Delay hides its content at first, then shows it after the given delay.
*
* This is useful for loading states: it can be disruptive to see a spinner or
* skeleton element for only a brief flash, we'd rather just show them if

View file

@ -275,43 +275,6 @@ const buildItemByNameLoader = (db, loaders) =>
{ cacheKeyFn: (name) => name.trim().toLowerCase() }
);
const buildItemSearchLoader = (db, loaders) =>
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) => {
// Split the query into words, and search for each word as a substring
// of the name.
const words = query.split(/\s+/);
const wordMatchersForMysql = words.map(
(word) => "%" + word.replace(/_%/g, "\\$0") + "%"
);
const matcherPlaceholders = words
.map((_) => "t.name LIKE ?")
.join(" AND ");
const [rows, _] = await db.execute(
`SELECT items.*, t.name FROM items
INNER JOIN item_translations t ON t.item_id = items.id
WHERE ${matcherPlaceholders} AND t.locale="en"
ORDER BY t.name
LIMIT 30`,
[...wordMatchersForMysql]
);
const entities = rows.map(normalizeRow);
for (const item of entities) {
loaders.itemLoader.prime(item.id, item);
}
return entities;
});
const responses = await Promise.all(queryPromises);
return responses;
});
const itemSearchKindConditions = {
// NOTE: We assume that items cannot have NC rarity and the PB description,
// so we don't bother to filter out PB items in the NC filter, for perf.
@ -320,6 +283,58 @@ const itemSearchKindConditions = {
PB: `description LIKE "%This item is part of a deluxe paint brush set!%"`,
};
const buildItemSearchLoader = (db, loaders) =>
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, itemKind, zoneIds = [], offset, limit }) => {
const actualOffset = offset || 0;
const actualLimit = Math.min(limit || 30, 30);
// Split the query into words, and search for each word as a substring
// of the name.
const words = query.split(/\s+/);
const wordMatchersForMysql = words.map(
(word) => "%" + word.replace(/_%/g, "\\$0") + "%"
);
const matcherPlaceholders = words
.map((_) => "t.name LIKE ?")
.join(" AND ");
const itemKindCondition = itemSearchKindConditions[itemKind] || "1";
const zoneIdsPlaceholder =
zoneIds.length > 0
? `swf_assets.zone_id IN (${zoneIds.map((_) => "?").join(", ")})`
: "1";
const [rows, _] = await db.execute(
`SELECT DISTINCT items.*, t.name FROM items
INNER JOIN item_translations t ON t.item_id = items.id
INNER JOIN parents_swf_assets rel
ON rel.parent_type = "Item" AND rel.parent_id = items.id
INNER JOIN swf_assets ON rel.swf_asset_id = swf_assets.id
WHERE ${matcherPlaceholders} AND t.locale = "en" AND
${zoneIdsPlaceholder} AND ${itemKindCondition}
ORDER BY t.name
LIMIT ? OFFSET ?`,
[...wordMatchersForMysql, ...zoneIds, actualLimit, actualOffset]
);
const entities = rows.map(normalizeRow);
for (const item of entities) {
loaders.itemLoader.prime(item.id, item);
}
return entities;
}
);
const responses = await Promise.all(queryPromises);
return responses;
});
const buildItemSearchToFitLoader = (db, loaders) =>
new DataLoader(async (queryAndBodyIdPairs) => {
// This isn't actually optimized as a batch query, we're just using a
@ -329,6 +344,8 @@ const buildItemSearchToFitLoader = (db, loaders) =>
const actualOffset = offset || 0;
const actualLimit = Math.min(limit || 30, 30);
// Split the query into words, and search for each word as a substring
// of the name.
const words = query.split(/\s+/);
const wordMatchersForMysql = words.map(
(word) => "%" + word.replace(/_%/g, "\\$0") + "%"
@ -336,6 +353,7 @@ const buildItemSearchToFitLoader = (db, loaders) =>
const matcherPlaceholders = words
.map((_) => "t.name LIKE ?")
.join(" AND ");
const itemKindCondition = itemSearchKindConditions[itemKind] || "1";
const zoneIdsPlaceholder =
zoneIds.length > 0

View file

@ -111,7 +111,13 @@ const typeDefs = gql`
itemsByName(names: [String!]!): [Item]!
# Search for items with fuzzy matching.
itemSearch(query: String!): ItemSearchResult!
itemSearch(
query: String!
itemKind: ItemKindSearchFilter
zoneIds: [ID!]
offset: Int
limit: Int
): ItemSearchResult!
itemSearchToFit(
query: String!
itemKind: ItemKindSearchFilter
@ -364,9 +370,20 @@ const resolvers = {
const items = await itemByNameLoader.loadMany(names);
return items.map(({ item }) => (item ? { id: item.id } : null));
},
itemSearch: async (_, { query }, { itemSearchLoader }) => {
const items = await itemSearchLoader.load(query.trim());
return { query, items };
itemSearch: async (
_,
{ query, itemKind, zoneIds = [], offset, limit },
{ itemSearchLoader }
) => {
const items = await itemSearchLoader.load({
query: query.trim(),
itemKind,
zoneIds,
offset,
limit,
});
const zones = zoneIds.map((id) => ({ id }));
return { query, zones, items };
},
itemSearchToFit: async (
_,