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:
parent
9e5dfd1c84
commit
b39914976b
9 changed files with 476 additions and 84 deletions
|
@ -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 />
|
||||
|
|
|
@ -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
287
src/app/ItemSearchPage.js
Normal 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;
|
|
@ -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 ||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
_,
|
||||
|
|
Loading…
Reference in a new issue