Add search toolbar to item page

Trickier than it looks, to get the shared UI across pages without disrupting things like focus!
This commit is contained in:
Emi Matchu 2021-09-30 20:04:50 -07:00
parent d6654b704b
commit 0a5d8f1f74
4 changed files with 125 additions and 84 deletions

View file

@ -1,7 +1,7 @@
import React from "react";
import { ApolloProvider } from "@apollo/client";
import { Auth0Provider } from "@auth0/auth0-react";
import { CSSReset, ChakraProvider, extendTheme } from "@chakra-ui/react";
import { CSSReset, ChakraProvider, extendTheme, Box } from "@chakra-ui/react";
import { mode } from "@chakra-ui/theme-tools";
import {
BrowserRouter as Router,
@ -45,6 +45,17 @@ const WardrobePage = loadable(() => import("./WardrobePage"), {
fallback: <WardrobePageLayout />,
});
// ItemPage and ItemSearchPage need to share a search toolbar, so here it is!
// It'll load in dynamically like the page elements, with a hacky fallback to
// take up 40px of height until it loads.
//
// There very well be a better way to encapsulate this! It's not *great* to
// have this here. I just don't wanna over abstract it just yet 😅
const ItemSearchPageToolbar = loadable(
() => import("./components/ItemSearchPageToolbar"),
{ fallback: <Box height="40px" /> }
);
const theme = extendTheme({
styles: {
global: (props) => ({
@ -116,6 +127,7 @@ function App() {
<Switch>
<Route path="/items/search/:query?">
<PageLayout>
<ItemSearchPageToolbar marginBottom="6" />
<ItemSearchPage />
</PageLayout>
</Route>
@ -131,6 +143,7 @@ function App() {
</Route>
<Route path="/items/:itemId">
<PageLayout>
<ItemSearchPageToolbar marginBottom="8" />
<ItemPage />
</PageLayout>
</Route>

View file

@ -31,7 +31,7 @@ import { useQuery, useMutation } from "@apollo/client";
import { Link, useParams } from "react-router-dom";
import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
import { Delay, logAndCapture, usePageTitle } from "./util";
import { Delay, logAndCapture, useLocalStorage, usePageTitle } from "./util";
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
import {
itemAppearanceFragment,
@ -44,7 +44,6 @@ import SpeciesColorPicker, {
getClosestPose,
} from "./components/SpeciesColorPicker";
import useCurrentUser from "./components/useCurrentUser";
import { useLocalStorage } from "./util";
import SpeciesFacesPicker, {
colorIsBasic,
} from "./ItemPage/SpeciesFacesPicker";

View file

@ -1,98 +1,21 @@
import React from "react";
import { Box, 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, {
import {
emptySearchQuery,
searchQueryIsEmpty,
} from "./WardrobePage/SearchToolbar";
import SquareItemCard, {
SquareItemCardSkeleton,
} from "./components/SquareItemCard";
import { Delay, MajorErrorMessage, useCommonStyles, useDebounce } from "./util";
import { Delay, MajorErrorMessage, useDebounce } from "./util";
import PaginationToolbar from "./components/PaginationToolbar";
import { useSearchQueryInUrl } from "./components/ItemSearchPageToolbar";
function ItemSearchPage() {
const [query, offset, setQuery] = useSearchQueryInUrl();
const { brightBackground } = useCommonStyles();
const { query: latestQuery, offset } = useSearchQueryInUrl();
return (
<Box>
<SearchToolbar
query={query}
onChange={setQuery}
showItemsLabel
background={brightBackground}
boxShadow="md"
autoFocus
/>
<Box height="6" />
<ItemSearchPageResults query={query} offset={offset} />
</Box>
);
}
/**
* useSearchQueryInUrl provides an API like useState, but stores the search
* query in the URL! It also parses out the offset for us.
*/
function useSearchQueryInUrl() {
const history = useHistory();
const { query: value } = useParams();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const query = {
value: decodeURIComponent(value || ""),
filterToZoneLabel: searchParams.get("zone") || null,
filterToItemKind: searchParams.get("kind") || null,
filterToCurrentUserOwnsOrWants: searchParams.get("user") || null,
};
const offset = parseInt(searchParams.get("offset")) || 0;
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);
}
if (newQuery.filterToCurrentUserOwnsOrWants) {
newParams.append("user", newQuery.filterToCurrentUserOwnsOrWants);
}
// NOTE: We omit `offset`, because changing the query should reset us
// back to the first page!
const search = newParams.toString();
if (search) {
url += "?" + search;
}
history.replace(url);
},
[history]
);
// NOTE: We don't provide a `setOffset`, because that's handled via
// pagination links.
return [query, offset, setQuery];
}
function ItemSearchPageResults({ query: latestQuery, offset }) {
// 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.

View file

@ -0,0 +1,106 @@
import React from "react";
import { useHistory, useLocation, useParams } from "react-router-dom";
import { useCommonStyles } from "../util";
import SearchToolbar from "../WardrobePage/SearchToolbar";
function ItemSearchPageToolbar({ ...props }) {
const { query, setQuery } = useSearchQueryInUrl();
const { brightBackground } = useCommonStyles();
return (
<SearchToolbar
query={query}
onChange={setQuery}
showItemsLabel
background={brightBackground}
boxShadow="md"
{...props}
/>
);
}
/**
* useSearchQueryInUrl provides an API like useState, but stores the search
* query in the URL! It also parses out the offset for us.
*/
export function useSearchQueryInUrl() {
const history = useHistory();
const { query: value } = useParams();
const { pathname, search } = useLocation();
// Parse the query from the location. (We memoize this because we use it as a
// dependency in the query-saving hook below.)
const parsedQuery = React.useMemo(() => {
const searchParams = new URLSearchParams(search);
return {
value: decodeURIComponent(value || ""),
filterToZoneLabel: searchParams.get("zone") || null,
filterToItemKind: searchParams.get("kind") || null,
filterToCurrentUserOwnsOrWants: searchParams.get("user") || null,
};
}, [search, value]);
const offset = parseInt(new URLSearchParams(search).get("offset")) || 0;
// While on the search page, save the most recent parsed query in state.
const isSearchPage = pathname.startsWith("/items/search");
const [savedQuery, setSavedQuery] = React.useState(parsedQuery);
React.useEffect(() => {
if (isSearchPage) {
setSavedQuery(parsedQuery);
}
}, [isSearchPage, parsedQuery]);
// Then, while not on the search page, use the saved query from state,
// instead of the (presumably empty) parsed query from the URL.
const query = isSearchPage ? parsedQuery : savedQuery;
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);
}
if (newQuery.filterToCurrentUserOwnsOrWants) {
newParams.append("user", newQuery.filterToCurrentUserOwnsOrWants);
}
// NOTE: We omit `offset`, because changing the query should reset us
// back to the first page!
const search = newParams.toString();
if (search) {
url += "?" + search;
}
// TODO: Tbh would be even nicer for this to be a like... timed thing?
// We use replace to avoid spamming the history too much, but sometimes
// the user's query meaningfully *does* change without intermediate
// navigation, like if they see the results and decide it's the wrong
// thing.
if (isSearchPage) {
history.replace(url);
} else {
// When you use the search toolbar from the item page, treat it as a
// full navigation!
history.push(url);
}
},
[history, isSearchPage]
);
// NOTE: We don't provide a `setOffset`, because that's handled via
// pagination links.
return { query, offset, setQuery };
}
export default ItemSearchPageToolbar;