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:
parent
d6654b704b
commit
0a5d8f1f74
4 changed files with 125 additions and 84 deletions
|
@ -1,7 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ApolloProvider } from "@apollo/client";
|
import { ApolloProvider } from "@apollo/client";
|
||||||
import { Auth0Provider } from "@auth0/auth0-react";
|
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 { mode } from "@chakra-ui/theme-tools";
|
||||||
import {
|
import {
|
||||||
BrowserRouter as Router,
|
BrowserRouter as Router,
|
||||||
|
@ -45,6 +45,17 @@ const WardrobePage = loadable(() => import("./WardrobePage"), {
|
||||||
fallback: <WardrobePageLayout />,
|
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({
|
const theme = extendTheme({
|
||||||
styles: {
|
styles: {
|
||||||
global: (props) => ({
|
global: (props) => ({
|
||||||
|
@ -116,6 +127,7 @@ function App() {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/items/search/:query?">
|
<Route path="/items/search/:query?">
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
|
<ItemSearchPageToolbar marginBottom="6" />
|
||||||
<ItemSearchPage />
|
<ItemSearchPage />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</Route>
|
</Route>
|
||||||
|
@ -131,6 +143,7 @@ function App() {
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/items/:itemId">
|
<Route path="/items/:itemId">
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
|
<ItemSearchPageToolbar marginBottom="8" />
|
||||||
<ItemPage />
|
<ItemPage />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
@ -31,7 +31,7 @@ import { useQuery, useMutation } from "@apollo/client";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
|
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 HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
|
||||||
import {
|
import {
|
||||||
itemAppearanceFragment,
|
itemAppearanceFragment,
|
||||||
|
@ -44,7 +44,6 @@ import SpeciesColorPicker, {
|
||||||
getClosestPose,
|
getClosestPose,
|
||||||
} from "./components/SpeciesColorPicker";
|
} from "./components/SpeciesColorPicker";
|
||||||
import useCurrentUser from "./components/useCurrentUser";
|
import useCurrentUser from "./components/useCurrentUser";
|
||||||
import { useLocalStorage } from "./util";
|
|
||||||
import SpeciesFacesPicker, {
|
import SpeciesFacesPicker, {
|
||||||
colorIsBasic,
|
colorIsBasic,
|
||||||
} from "./ItemPage/SpeciesFacesPicker";
|
} from "./ItemPage/SpeciesFacesPicker";
|
||||||
|
|
|
@ -1,98 +1,21 @@
|
||||||
import React from "react";
|
|
||||||
import { Box, Wrap, WrapItem } from "@chakra-ui/react";
|
import { Box, Wrap, WrapItem } from "@chakra-ui/react";
|
||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import { useHistory, useLocation, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import SearchToolbar, {
|
import {
|
||||||
emptySearchQuery,
|
emptySearchQuery,
|
||||||
searchQueryIsEmpty,
|
searchQueryIsEmpty,
|
||||||
} from "./WardrobePage/SearchToolbar";
|
} from "./WardrobePage/SearchToolbar";
|
||||||
import SquareItemCard, {
|
import SquareItemCard, {
|
||||||
SquareItemCardSkeleton,
|
SquareItemCardSkeleton,
|
||||||
} from "./components/SquareItemCard";
|
} from "./components/SquareItemCard";
|
||||||
import { Delay, MajorErrorMessage, useCommonStyles, useDebounce } from "./util";
|
import { Delay, MajorErrorMessage, useDebounce } from "./util";
|
||||||
import PaginationToolbar from "./components/PaginationToolbar";
|
import PaginationToolbar from "./components/PaginationToolbar";
|
||||||
|
import { useSearchQueryInUrl } from "./components/ItemSearchPageToolbar";
|
||||||
|
|
||||||
function ItemSearchPage() {
|
function ItemSearchPage() {
|
||||||
const [query, offset, setQuery] = useSearchQueryInUrl();
|
const { query: latestQuery, offset } = useSearchQueryInUrl();
|
||||||
const { brightBackground } = useCommonStyles();
|
|
||||||
|
|
||||||
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
|
// 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
|
// 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.
|
// SearchPanel pagination is a bit of a mess and will need refactoring.
|
||||||
|
|
106
src/app/components/ItemSearchPageToolbar.js
Normal file
106
src/app/components/ItemSearchPageToolbar.js
Normal 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;
|
Loading…
Reference in a new issue