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
|
// Loading the page will often fail after a deploy, because Vercel doesn't keep
|
||||||
// old JS chunks on the CDN. Recover by reloading!
|
// old JS chunks on the CDN. Recover by reloading!
|
||||||
const tryLoadable = (load) =>
|
const tryLoadable = (load, options) =>
|
||||||
loadable(() =>
|
loadable(
|
||||||
load().catch((e) => {
|
() =>
|
||||||
console.error("Error loading page, reloading", e);
|
load().catch((e) => {
|
||||||
window.location.reload();
|
console.error("Error loading page, reloading", e);
|
||||||
})
|
window.location.reload();
|
||||||
|
}),
|
||||||
|
options
|
||||||
);
|
);
|
||||||
|
|
||||||
const HomePage = tryLoadable(() => import("./HomePage"));
|
const HomePage = tryLoadable(() => import("./HomePage"));
|
||||||
|
const ItemSearchPage = tryLoadable(() => import("./ItemSearchPage"));
|
||||||
const ItemPage = tryLoadable(() => import("./ItemPage"));
|
const ItemPage = tryLoadable(() => import("./ItemPage"));
|
||||||
const ItemTradesOfferingPage = tryLoadable(() =>
|
const ItemTradesOfferingPage = tryLoadable(() =>
|
||||||
import("./ItemTradesPage").then((m) => m.ItemTradesOfferingPage)
|
import("./ItemTradesPage").then((m) => m.ItemTradesOfferingPage)
|
||||||
|
@ -107,6 +110,11 @@ function App() {
|
||||||
<ChakraProvider theme={theme}>
|
<ChakraProvider theme={theme}>
|
||||||
<CSSReset />
|
<CSSReset />
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route path="/items/search/:query?">
|
||||||
|
<PageLayout>
|
||||||
|
<ItemSearchPage />
|
||||||
|
</PageLayout>
|
||||||
|
</Route>
|
||||||
<Route path="/items/:itemId/trades/offering">
|
<Route path="/items/:itemId/trades/offering">
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<ItemTradesOfferingPage />
|
<ItemTradesOfferingPage />
|
||||||
|
|
|
@ -6,13 +6,18 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Flex,
|
Flex,
|
||||||
HStack,
|
HStack,
|
||||||
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
InputRightElement,
|
||||||
Textarea,
|
Textarea,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
useTheme,
|
useTheme,
|
||||||
useToast,
|
useToast,
|
||||||
VStack,
|
VStack,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
|
import { ArrowForwardIcon, SearchIcon } from "@chakra-ui/icons";
|
||||||
import { useHistory, useLocation } from "react-router-dom";
|
import { useHistory, useLocation } from "react-router-dom";
|
||||||
import { useLazyQuery, useQuery } from "@apollo/client";
|
import { useLazyQuery, useQuery } from "@apollo/client";
|
||||||
|
|
||||||
|
@ -281,12 +286,67 @@ function SubmitPetForm() {
|
||||||
function NewItemsSection() {
|
function NewItemsSection() {
|
||||||
return (
|
return (
|
||||||
<Box width="100%">
|
<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 />
|
<NewItemsSectionContent />
|
||||||
</Box>
|
</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() {
|
function NewItemsSectionContent() {
|
||||||
const { loading, error, data } = useQuery(
|
const { loading, error, data } = useQuery(
|
||||||
gql`
|
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 { Box, Flex } from "@chakra-ui/react";
|
||||||
|
|
||||||
import ItemsPanel from "./ItemsPanel";
|
import ItemsPanel from "./ItemsPanel";
|
||||||
import SearchToolbar from "./SearchToolbar";
|
import SearchToolbar, { emptySearchQuery } from "./SearchToolbar";
|
||||||
import SearchPanel from "./SearchPanel";
|
import SearchPanel from "./SearchPanel";
|
||||||
|
|
||||||
const emptyQuery = { value: "", filterToZoneLabel: null };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ItemsAndSearchPanels manages the shared layout and state for:
|
* ItemsAndSearchPanels manages the shared layout and state for:
|
||||||
* - ItemsPanel, which shows the items in the outfit now, and
|
* - ItemsPanel, which shows the items in the outfit now, and
|
||||||
|
@ -21,16 +19,11 @@ const emptyQuery = { value: "", filterToZoneLabel: null };
|
||||||
* state and refs.
|
* state and refs.
|
||||||
*/
|
*/
|
||||||
function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
|
function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
|
||||||
const [searchQuery, setSearchQuery] = React.useState(emptyQuery);
|
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
|
||||||
const scrollContainerRef = React.useRef();
|
const scrollContainerRef = React.useRef();
|
||||||
const searchQueryRef = React.useRef();
|
const searchQueryRef = React.useRef();
|
||||||
const firstSearchResultRef = React.useRef();
|
const firstSearchResultRef = React.useRef();
|
||||||
|
|
||||||
const onChange = React.useCallback(
|
|
||||||
(newQuery) => setSearchQuery(newQuery || emptyQuery),
|
|
||||||
[setSearchQuery]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" height="100%">
|
<Flex direction="column" height="100%">
|
||||||
<Box px="5" py="3" boxShadow="sm">
|
<Box px="5" py="3" boxShadow="sm">
|
||||||
|
@ -38,7 +31,7 @@ function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
|
||||||
query={searchQuery}
|
query={searchQuery}
|
||||||
searchQueryRef={searchQueryRef}
|
searchQueryRef={searchQueryRef}
|
||||||
firstSearchResultRef={firstSearchResultRef}
|
firstSearchResultRef={firstSearchResultRef}
|
||||||
onChange={onChange}
|
onChange={setSearchQuery}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{searchQuery.value ||
|
{searchQuery.value ||
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Box, Text, VisuallyHidden } from "@chakra-ui/react";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
|
|
||||||
import { Delay, useDebounce } from "../util";
|
import { Delay, useDebounce } from "../util";
|
||||||
|
import { emptySearchQuery } from "./SearchToolbar";
|
||||||
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
||||||
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
|
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
|
||||||
|
|
||||||
|
@ -217,11 +218,7 @@ function useSearchResults(query, outfitState) {
|
||||||
// the user types anything.
|
// the user types anything.
|
||||||
const debouncedQuery = useDebounce(query, 300, {
|
const debouncedQuery = useDebounce(query, 300, {
|
||||||
waitForFirstPause: true,
|
waitForFirstPause: true,
|
||||||
initialValue: {
|
initialValue: emptySearchQuery,
|
||||||
value: "",
|
|
||||||
filterToItemKind: null,
|
|
||||||
filterToZoneLabel: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the query changes, we should update our impression of whether we've
|
// 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 { ClassNames } from "@emotion/react";
|
||||||
import Autosuggest from "react-autosuggest";
|
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,
|
* SearchToolbar is rendered above both the ItemsPanel and the SearchPanel,
|
||||||
* and contains the search field where the user types their query.
|
* and contains the search field where the user types their query.
|
||||||
|
@ -29,6 +39,9 @@ function SearchToolbar({
|
||||||
searchQueryRef,
|
searchQueryRef,
|
||||||
firstSearchResultRef,
|
firstSearchResultRef,
|
||||||
onChange,
|
onChange,
|
||||||
|
showItemsLabel = false,
|
||||||
|
background = null,
|
||||||
|
boxShadow = null,
|
||||||
}) {
|
}) {
|
||||||
const [suggestions, setSuggestions] = React.useState([]);
|
const [suggestions, setSuggestions] = React.useState([]);
|
||||||
|
|
||||||
|
@ -110,7 +123,21 @@ function SearchToolbar({
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
}, [query.filterToItemKind, query.filterToZoneLabel]);
|
}, [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");
|
const focusBorderColor = useColorModeValue("green.600", "green.400");
|
||||||
|
|
||||||
|
@ -137,8 +164,8 @@ function SearchToolbar({
|
||||||
highlightFirstSuggestion={true}
|
highlightFirstSuggestion={true}
|
||||||
renderSuggestion={renderSuggestion}
|
renderSuggestion={renderSuggestion}
|
||||||
renderSuggestionsContainer={renderSuggestionsContainer}
|
renderSuggestionsContainer={renderSuggestionsContainer}
|
||||||
renderInputComponent={(props) => (
|
renderInputComponent={(inputProps) => (
|
||||||
<InputGroup>
|
<InputGroup boxShadow={boxShadow} borderRadius="md">
|
||||||
{queryFilterText ? (
|
{queryFilterText ? (
|
||||||
<InputLeftAddon>
|
<InputLeftAddon>
|
||||||
<SearchIcon color="gray.400" marginRight="3" />
|
<SearchIcon color="gray.400" marginRight="3" />
|
||||||
|
@ -149,7 +176,7 @@ function SearchToolbar({
|
||||||
<SearchIcon color="gray.400" />
|
<SearchIcon color="gray.400" />
|
||||||
</InputLeftElement>
|
</InputLeftElement>
|
||||||
)}
|
)}
|
||||||
<Input {...props} />
|
<Input background={background} {...inputProps} />
|
||||||
{(query.value || queryFilterText) && (
|
{(query.value || queryFilterText) && (
|
||||||
<InputRightElement>
|
<InputRightElement>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -158,9 +185,7 @@ function SearchToolbar({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
colorScheme="green"
|
colorScheme="green"
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
onClick={() => {
|
onClick={() => onChange(emptySearchQuery)}
|
||||||
onChange(null);
|
|
||||||
}}
|
|
||||||
// Big style hacks here!
|
// Big style hacks here!
|
||||||
height="calc(100% - 2px)"
|
height="calc(100% - 2px)"
|
||||||
marginRight="2px"
|
marginRight="2px"
|
||||||
|
@ -176,19 +201,6 @@ function SearchToolbar({
|
||||||
value: query.value || "",
|
value: query.value || "",
|
||||||
ref: searchQueryRef,
|
ref: searchQueryRef,
|
||||||
minWidth: 0,
|
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 }) => {
|
onChange: (e, { newValue, method }) => {
|
||||||
// The Autosuggest tries to change the _entire_ value of the element
|
// The Autosuggest tries to change the _entire_ value of the element
|
||||||
// when navigating suggestions, which isn't actually what we want.
|
// 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";
|
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
|
* 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
|
* 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() }
|
{ 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 = {
|
const itemSearchKindConditions = {
|
||||||
// NOTE: We assume that items cannot have NC rarity and the PB description,
|
// 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.
|
// 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!%"`,
|
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) =>
|
const buildItemSearchToFitLoader = (db, loaders) =>
|
||||||
new DataLoader(async (queryAndBodyIdPairs) => {
|
new DataLoader(async (queryAndBodyIdPairs) => {
|
||||||
// This isn't actually optimized as a batch query, we're just using a
|
// 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 actualOffset = offset || 0;
|
||||||
const actualLimit = Math.min(limit || 30, 30);
|
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 words = query.split(/\s+/);
|
||||||
const wordMatchersForMysql = words.map(
|
const wordMatchersForMysql = words.map(
|
||||||
(word) => "%" + word.replace(/_%/g, "\\$0") + "%"
|
(word) => "%" + word.replace(/_%/g, "\\$0") + "%"
|
||||||
|
@ -336,6 +353,7 @@ const buildItemSearchToFitLoader = (db, loaders) =>
|
||||||
const matcherPlaceholders = words
|
const matcherPlaceholders = words
|
||||||
.map((_) => "t.name LIKE ?")
|
.map((_) => "t.name LIKE ?")
|
||||||
.join(" AND ");
|
.join(" AND ");
|
||||||
|
|
||||||
const itemKindCondition = itemSearchKindConditions[itemKind] || "1";
|
const itemKindCondition = itemSearchKindConditions[itemKind] || "1";
|
||||||
const zoneIdsPlaceholder =
|
const zoneIdsPlaceholder =
|
||||||
zoneIds.length > 0
|
zoneIds.length > 0
|
||||||
|
|
|
@ -111,7 +111,13 @@ const typeDefs = gql`
|
||||||
itemsByName(names: [String!]!): [Item]!
|
itemsByName(names: [String!]!): [Item]!
|
||||||
|
|
||||||
# Search for items with fuzzy matching.
|
# Search for items with fuzzy matching.
|
||||||
itemSearch(query: String!): ItemSearchResult!
|
itemSearch(
|
||||||
|
query: String!
|
||||||
|
itemKind: ItemKindSearchFilter
|
||||||
|
zoneIds: [ID!]
|
||||||
|
offset: Int
|
||||||
|
limit: Int
|
||||||
|
): ItemSearchResult!
|
||||||
itemSearchToFit(
|
itemSearchToFit(
|
||||||
query: String!
|
query: String!
|
||||||
itemKind: ItemKindSearchFilter
|
itemKind: ItemKindSearchFilter
|
||||||
|
@ -364,9 +370,20 @@ const resolvers = {
|
||||||
const items = await itemByNameLoader.loadMany(names);
|
const items = await itemByNameLoader.loadMany(names);
|
||||||
return items.map(({ item }) => (item ? { id: item.id } : null));
|
return items.map(({ item }) => (item ? { id: item.id } : null));
|
||||||
},
|
},
|
||||||
itemSearch: async (_, { query }, { itemSearchLoader }) => {
|
itemSearch: async (
|
||||||
const items = await itemSearchLoader.load(query.trim());
|
_,
|
||||||
return { query, items };
|
{ 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 (
|
itemSearchToFit: async (
|
||||||
_,
|
_,
|
||||||
|
|
Loading…
Reference in a new issue