From b39914976b47c81c670938330419be5b647c3d3d Mon Sep 17 00:00:00 2001 From: Matchu Date: Mon, 18 Jan 2021 15:56:24 -0800 Subject: [PATCH] Add an /items/search page, and search box on Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 πŸ˜… --- src/app/App.js | 20 +- src/app/HomePage.js | 62 +++- src/app/ItemSearchPage.js | 287 +++++++++++++++++++ src/app/WardrobePage/ItemsAndSearchPanels.js | 13 +- src/app/WardrobePage/SearchPanel.js | 7 +- src/app/WardrobePage/SearchToolbar.js | 52 ++-- src/app/util.js | 2 +- src/server/loaders.js | 92 +++--- src/server/types/Item.js | 25 +- 9 files changed, 476 insertions(+), 84 deletions(-) create mode 100644 src/app/ItemSearchPage.js diff --git a/src/app/App.js b/src/app/App.js index 84f3b55..bcde5a1 100644 --- a/src/app/App.js +++ b/src/app/App.js @@ -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() { + + + + + diff --git a/src/app/HomePage.js b/src/app/HomePage.js index 15aca5d..60c8f21 100644 --- a/src/app/HomePage.js +++ b/src/app/HomePage.js @@ -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 ( - Latest items + + + Latest items + + + + + ); } +function ItemsSearchField() { + const [query, setQuery] = React.useState(""); + const { brightBackground } = useCommonStyles(); + const history = useHistory(); + + return ( +
{ + if (query) { + history.push(`/items/search/${encodeURIComponent(query)}`); + } + }} + > + + + + + setQuery(e.target.value)} + placeholder="Search all items…" + borderRadius="full" + /> + + } + 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"} + /> + + +
+ ); +} + function NewItemsSectionContent() { const { loading, error, data } = useQuery( gql` diff --git a/src/app/ItemSearchPage.js b/src/app/ItemSearchPage.js new file mode 100644 index 0000000..28c1cbd --- /dev/null +++ b/src/app/ItemSearchPage.js @@ -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 ( + + + + + + ); +} + +/** + * 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 ( + + + + ); + } + + if (error) { + return ( + + Oops, we couldn't load the search results. Check your connection and try + again! + + ); + } + + return ( + + + {data.itemSearch.items.map((item) => ( + + + + ))} + + {data.itemSearch.items.length >= 30 && ( + + + We only show the first 30 results for now! πŸ˜… + + + )} + + ); +} + +function ItemSearchPageResultsLoading() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default ItemSearchPage; diff --git a/src/app/WardrobePage/ItemsAndSearchPanels.js b/src/app/WardrobePage/ItemsAndSearchPanels.js index fb0669b..62c6ee0 100644 --- a/src/app/WardrobePage/ItemsAndSearchPanels.js +++ b/src/app/WardrobePage/ItemsAndSearchPanels.js @@ -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 ( @@ -38,7 +31,7 @@ function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) { query={searchQuery} searchQueryRef={searchQueryRef} firstSearchResultRef={firstSearchResultRef} - onChange={onChange} + onChange={setSearchQuery} /> {searchQuery.value || diff --git a/src/app/WardrobePage/SearchPanel.js b/src/app/WardrobePage/SearchPanel.js index 2fde4ca..466d160 100644 --- a/src/app/WardrobePage/SearchPanel.js +++ b/src/app/WardrobePage/SearchPanel.js @@ -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 diff --git a/src/app/WardrobePage/SearchToolbar.js b/src/app/WardrobePage/SearchToolbar.js index e66096d..edacca6 100644 --- a/src/app/WardrobePage/SearchToolbar.js +++ b/src/app/WardrobePage/SearchToolbar.js @@ -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 ? ( + <> + + Items: + {" "} + {queryFilterText} + + ) : ( + + Items + + ); + } const focusBorderColor = useColorModeValue("green.600", "green.400"); @@ -137,8 +164,8 @@ function SearchToolbar({ highlightFirstSuggestion={true} renderSuggestion={renderSuggestion} renderSuggestionsContainer={renderSuggestionsContainer} - renderInputComponent={(props) => ( - + renderInputComponent={(inputProps) => ( + {queryFilterText ? ( @@ -149,7 +176,7 @@ function SearchToolbar({ )} - + {(query.value || queryFilterText) && ( { - 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. diff --git a/src/app/util.js b/src/app/util.js index 32a3d22..e042f9b 100644 --- a/src/app/util.js +++ b/src/app/util.js @@ -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 diff --git a/src/server/loaders.js b/src/server/loaders.js index ea1670a..6428d1f 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -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 diff --git a/src/server/types/Item.js b/src/server/types/Item.js index 8965800..83134db 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -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 ( _,