2023-08-10 15:56:36 -07:00
|
|
|
import React from "react";
|
|
|
|
import gql from "graphql-tag";
|
|
|
|
import { useQuery } from "@apollo/client";
|
|
|
|
import {
|
|
|
|
Box,
|
|
|
|
IconButton,
|
|
|
|
Input,
|
|
|
|
InputGroup,
|
|
|
|
InputLeftAddon,
|
|
|
|
InputLeftElement,
|
|
|
|
InputRightElement,
|
|
|
|
Tooltip,
|
|
|
|
useColorModeValue,
|
|
|
|
} from "@chakra-ui/react";
|
|
|
|
import {
|
|
|
|
ChevronDownIcon,
|
|
|
|
ChevronUpIcon,
|
|
|
|
CloseIcon,
|
|
|
|
SearchIcon,
|
|
|
|
} from "@chakra-ui/icons";
|
|
|
|
import { ClassNames } from "@emotion/react";
|
|
|
|
import Autosuggest from "react-autosuggest";
|
|
|
|
|
|
|
|
import useCurrentUser from "../components/useCurrentUser";
|
|
|
|
import { logAndCapture } from "../util";
|
|
|
|
|
|
|
|
export const emptySearchQuery = {
|
|
|
|
value: "",
|
|
|
|
filterToZoneLabel: null,
|
|
|
|
filterToItemKind: null,
|
|
|
|
filterToCurrentUserOwnsOrWants: null,
|
|
|
|
};
|
|
|
|
|
|
|
|
export function searchQueryIsEmpty(query) {
|
|
|
|
return Object.values(query).every((value) => !value);
|
|
|
|
}
|
|
|
|
|
|
|
|
const SUGGESTIONS_PLACEMENT_PROPS = {
|
|
|
|
inline: {
|
|
|
|
borderBottomRadius: "md",
|
|
|
|
},
|
|
|
|
top: {
|
|
|
|
position: "absolute",
|
|
|
|
bottom: "100%",
|
|
|
|
borderTopRadius: "md",
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* SearchToolbar is rendered above both the ItemsPanel and the SearchPanel,
|
|
|
|
* and contains the search field where the user types their query.
|
|
|
|
*
|
|
|
|
* It has some subtle keyboard interaction support, like DownArrow to go to the
|
|
|
|
* first search result, and Escape to clear the search and go back to the
|
|
|
|
* ItemsPanel. (The SearchPanel can also send focus back to here, with Escape
|
|
|
|
* from anywhere, or UpArrow from the first result!)
|
|
|
|
*/
|
|
|
|
function SearchToolbar({
|
|
|
|
query,
|
|
|
|
searchQueryRef,
|
|
|
|
firstSearchResultRef = null,
|
|
|
|
onChange,
|
|
|
|
autoFocus,
|
|
|
|
showItemsLabel = false,
|
|
|
|
background = null,
|
|
|
|
boxShadow = null,
|
|
|
|
suggestionsPlacement = "inline",
|
|
|
|
...props
|
|
|
|
}) {
|
|
|
|
const [suggestions, setSuggestions] = React.useState([]);
|
|
|
|
const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
|
|
|
|
const { isLoggedIn } = useCurrentUser();
|
|
|
|
|
|
|
|
// NOTE: This query should always load ~instantly, from the client cache.
|
|
|
|
const { data } = useQuery(gql`
|
|
|
|
query SearchToolbarZones {
|
|
|
|
allZones {
|
|
|
|
id
|
|
|
|
label
|
|
|
|
depth
|
|
|
|
isCommonlyUsedByItems
|
|
|
|
}
|
|
|
|
}
|
|
|
|
`);
|
|
|
|
const zones = data?.allZones || [];
|
|
|
|
const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
|
|
|
|
|
|
|
|
let zoneLabels = itemZones.map((z) => z.label);
|
|
|
|
zoneLabels = [...new Set(zoneLabels)];
|
|
|
|
zoneLabels.sort();
|
|
|
|
|
|
|
|
const onMoveFocusDownToResults = (e) => {
|
|
|
|
if (firstSearchResultRef && firstSearchResultRef.current) {
|
|
|
|
firstSearchResultRef.current.focus();
|
|
|
|
e.preventDefault();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
|
|
|
|
const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
|
|
|
|
|
|
|
|
const renderSuggestion = React.useCallback(
|
|
|
|
({ text }, { isHighlighted }) => (
|
|
|
|
<Box
|
|
|
|
fontWeight={isHighlighted ? "bold" : "normal"}
|
|
|
|
background={isHighlighted ? highlightedBgColor : suggestionBgColor}
|
|
|
|
padding="2"
|
|
|
|
paddingLeft="2.5rem"
|
|
|
|
fontSize="sm"
|
|
|
|
>
|
|
|
|
{text}
|
|
|
|
</Box>
|
|
|
|
),
|
2023-10-24 16:45:49 -07:00
|
|
|
[suggestionBgColor, highlightedBgColor],
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
const renderSuggestionsContainer = React.useCallback(
|
|
|
|
({ containerProps, children }) => {
|
|
|
|
const { className, ...otherContainerProps } = containerProps;
|
|
|
|
return (
|
|
|
|
<ClassNames>
|
|
|
|
{({ css, cx }) => (
|
|
|
|
<Box
|
|
|
|
{...otherContainerProps}
|
|
|
|
boxShadow="md"
|
|
|
|
overflow="auto"
|
|
|
|
transition="all 0.4s"
|
|
|
|
maxHeight="48"
|
|
|
|
width="100%"
|
|
|
|
className={cx(
|
|
|
|
className,
|
|
|
|
css`
|
|
|
|
li {
|
|
|
|
list-style: none;
|
|
|
|
}
|
2023-10-24 16:45:49 -07:00
|
|
|
`,
|
2023-08-10 15:56:36 -07:00
|
|
|
)}
|
|
|
|
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
|
|
|
|
>
|
|
|
|
{children}
|
|
|
|
{!children && advancedSearchIsOpen && (
|
|
|
|
<Box
|
|
|
|
padding="4"
|
|
|
|
fontSize="sm"
|
|
|
|
fontStyle="italic"
|
|
|
|
textAlign="center"
|
|
|
|
>
|
|
|
|
No more filters available!
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
|
|
|
);
|
|
|
|
},
|
2023-10-24 16:45:49 -07:00
|
|
|
[advancedSearchIsOpen, suggestionsPlacement],
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
// When we change the query filters, clear out the suggestions.
|
|
|
|
React.useEffect(() => {
|
|
|
|
setSuggestions([]);
|
|
|
|
}, [
|
|
|
|
query.filterToItemKind,
|
|
|
|
query.filterToZoneLabel,
|
|
|
|
query.filterToCurrentUserOwnsOrWants,
|
|
|
|
]);
|
|
|
|
|
|
|
|
let queryFilterText = getQueryFilterText(query);
|
|
|
|
if (showItemsLabel) {
|
|
|
|
queryFilterText = queryFilterText ? (
|
|
|
|
<>
|
|
|
|
<Box as="span" fontWeight="600">
|
|
|
|
Items:
|
|
|
|
</Box>{" "}
|
|
|
|
{queryFilterText}
|
|
|
|
</>
|
|
|
|
) : (
|
|
|
|
<Box as="span" fontWeight="600">
|
|
|
|
Items
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
|
|
|
|
showAll: true,
|
|
|
|
});
|
|
|
|
|
|
|
|
// Once you remove the final suggestion available, close Advanced Search. We
|
|
|
|
// have placeholder text available, sure, but this feels more natural!
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (allSuggestions.length === 0) {
|
|
|
|
setAdvancedSearchIsOpen(false);
|
|
|
|
}
|
|
|
|
}, [allSuggestions.length]);
|
|
|
|
|
|
|
|
const focusBorderColor = useColorModeValue("green.600", "green.400");
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Box position="relative" {...props}>
|
|
|
|
<Autosuggest
|
|
|
|
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
|
|
|
|
onSuggestionsFetchRequested={({ value }) => {
|
|
|
|
// HACK: I'm not sure why, but apparently this gets called with value
|
|
|
|
// set to the _chosen suggestion_ after choosing it? Has that
|
|
|
|
// always happened? Idk? Let's just, gate around it, I guess?
|
|
|
|
if (typeof value === "string") {
|
|
|
|
setSuggestions(
|
2023-10-24 16:45:49 -07:00
|
|
|
getSuggestions(value, query, zoneLabels, isLoggedIn),
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
onSuggestionSelected={(e, { suggestion }) => {
|
|
|
|
onChange({
|
|
|
|
...query,
|
|
|
|
// If the suggestion was from typing, remove the last word of the
|
|
|
|
// query value. Or, if it was from Advanced Search, leave it alone!
|
|
|
|
value: advancedSearchIsOpen
|
|
|
|
? query.value
|
|
|
|
: removeLastWord(query.value),
|
|
|
|
filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
|
|
|
|
filterToItemKind: suggestion.itemKind || query.filterToItemKind,
|
|
|
|
filterToCurrentUserOwnsOrWants:
|
|
|
|
suggestion.userOwnsOrWants ||
|
|
|
|
query.filterToCurrentUserOwnsOrWants,
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
getSuggestionValue={(zl) => zl}
|
|
|
|
alwaysRenderSuggestions={true}
|
|
|
|
renderSuggestion={renderSuggestion}
|
|
|
|
renderSuggestionsContainer={renderSuggestionsContainer}
|
|
|
|
renderInputComponent={(inputProps) => (
|
|
|
|
<InputGroup boxShadow={boxShadow} borderRadius="md">
|
|
|
|
{queryFilterText ? (
|
|
|
|
<InputLeftAddon>
|
|
|
|
<SearchIcon color="gray.400" marginRight="3" />
|
|
|
|
<Box fontSize="sm">{queryFilterText}</Box>
|
|
|
|
</InputLeftAddon>
|
|
|
|
) : (
|
|
|
|
<InputLeftElement>
|
|
|
|
<SearchIcon color="gray.400" />
|
|
|
|
</InputLeftElement>
|
|
|
|
)}
|
|
|
|
<Input
|
|
|
|
background={background}
|
|
|
|
autoFocus={autoFocus}
|
|
|
|
{...inputProps}
|
|
|
|
/>
|
|
|
|
<InputRightElement
|
|
|
|
width="auto"
|
|
|
|
justifyContent="flex-end"
|
|
|
|
paddingRight="2px"
|
|
|
|
paddingY="2px"
|
|
|
|
>
|
|
|
|
{!searchQueryIsEmpty(query) && (
|
|
|
|
<Tooltip label="Clear">
|
|
|
|
<IconButton
|
|
|
|
icon={<CloseIcon fontSize="0.6em" />}
|
|
|
|
color="gray.400"
|
|
|
|
variant="ghost"
|
|
|
|
height="100%"
|
|
|
|
marginLeft="1"
|
|
|
|
aria-label="Clear search"
|
|
|
|
onClick={() => {
|
|
|
|
setSuggestions([]);
|
|
|
|
onChange(emptySearchQuery);
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</Tooltip>
|
|
|
|
)}
|
|
|
|
<Tooltip label="Advanced search">
|
|
|
|
<IconButton
|
|
|
|
icon={
|
|
|
|
advancedSearchIsOpen ? (
|
|
|
|
<ChevronUpIcon fontSize="1.5em" />
|
|
|
|
) : (
|
|
|
|
<ChevronDownIcon fontSize="1.5em" />
|
|
|
|
)
|
|
|
|
}
|
|
|
|
color="gray.400"
|
|
|
|
variant="ghost"
|
|
|
|
height="100%"
|
|
|
|
aria-label="Open advanced search"
|
|
|
|
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
|
|
|
|
/>
|
|
|
|
</Tooltip>
|
|
|
|
</InputRightElement>
|
|
|
|
</InputGroup>
|
|
|
|
)}
|
|
|
|
inputProps={{
|
|
|
|
placeholder: "Search all items…",
|
|
|
|
focusBorderColor: focusBorderColor,
|
|
|
|
value: query.value || "",
|
|
|
|
ref: searchQueryRef,
|
|
|
|
minWidth: 0,
|
|
|
|
"data-test-id": "item-search-input",
|
|
|
|
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.
|
|
|
|
// Only accept value changes that are typed by the user!
|
|
|
|
if (method === "type") {
|
|
|
|
onChange({ ...query, value: newValue });
|
|
|
|
}
|
|
|
|
},
|
|
|
|
onKeyDown: (e) => {
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
if (suggestions.length > 0) {
|
|
|
|
setSuggestions([]);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
onChange(emptySearchQuery);
|
|
|
|
e.target.blur();
|
|
|
|
} else if (e.key === "Enter") {
|
|
|
|
// Pressing Enter doesn't actually submit because it's all on
|
|
|
|
// debounce, but it can be a declaration that the query is done, so
|
|
|
|
// filter suggestions should go away!
|
|
|
|
if (suggestions.length > 0) {
|
|
|
|
setSuggestions([]);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
} else if (e.key === "ArrowDown") {
|
|
|
|
if (suggestions.length > 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
onMoveFocusDownToResults(e);
|
|
|
|
} else if (e.key === "Backspace" && e.target.selectionStart === 0) {
|
|
|
|
onChange({
|
|
|
|
...query,
|
|
|
|
filterToItemKind: null,
|
|
|
|
filterToZoneLabel: null,
|
|
|
|
filterToCurrentUserOwnsOrWants: null,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getSuggestions(
|
|
|
|
value,
|
|
|
|
query,
|
|
|
|
zoneLabels,
|
|
|
|
isLoggedIn,
|
2023-10-24 16:45:49 -07:00
|
|
|
{ showAll = false } = {},
|
2023-08-10 15:56:36 -07:00
|
|
|
) {
|
|
|
|
if (!value && !showAll) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
const words = (value || "").split(/\s+/);
|
|
|
|
const lastWord = words[words.length - 1];
|
|
|
|
if (lastWord.length < 2 && !showAll) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
const suggestions = [];
|
|
|
|
|
|
|
|
if (query.filterToItemKind == null) {
|
|
|
|
if (
|
|
|
|
wordMatches("NC", lastWord) ||
|
|
|
|
wordMatches("Neocash", lastWord) ||
|
|
|
|
showAll
|
|
|
|
) {
|
|
|
|
suggestions.push({ itemKind: "NC", text: "Neocash items" });
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
wordMatches("NP", lastWord) ||
|
|
|
|
wordMatches("Neopoints", lastWord) ||
|
|
|
|
showAll
|
|
|
|
) {
|
|
|
|
suggestions.push({ itemKind: "NP", text: "Neopoint items" });
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
wordMatches("PB", lastWord) ||
|
|
|
|
wordMatches("Paintbrush", lastWord) ||
|
|
|
|
showAll
|
|
|
|
) {
|
|
|
|
suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
|
|
|
|
if (wordMatches("Items you own", lastWord) || showAll) {
|
|
|
|
suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
|
|
|
|
}
|
|
|
|
|
|
|
|
if (wordMatches("Items you want", lastWord) || showAll) {
|
|
|
|
suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (query.filterToZoneLabel == null) {
|
|
|
|
for (const zoneLabel of zoneLabels) {
|
|
|
|
if (wordMatches(zoneLabel, lastWord) || showAll) {
|
|
|
|
suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return suggestions;
|
|
|
|
}
|
|
|
|
|
|
|
|
function wordMatches(target, word) {
|
|
|
|
return target.toLowerCase().includes(word.toLowerCase());
|
|
|
|
}
|
|
|
|
|
|
|
|
function getQueryFilterText(query) {
|
|
|
|
const textWords = [];
|
|
|
|
|
|
|
|
if (query.filterToItemKind) {
|
|
|
|
textWords.push(query.filterToItemKind);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (query.filterToZoneLabel) {
|
|
|
|
textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
|
|
|
|
}
|
|
|
|
|
|
|
|
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
|
|
|
|
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
|
|
|
textWords.push("Items");
|
|
|
|
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
|
|
|
textWords.push("items");
|
|
|
|
}
|
|
|
|
textWords.push("you own");
|
|
|
|
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
|
|
|
|
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
|
|
|
textWords.push("Items");
|
|
|
|
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
|
|
|
textWords.push("items");
|
|
|
|
}
|
|
|
|
textWords.push("you want");
|
|
|
|
}
|
|
|
|
|
|
|
|
return textWords.join(" ");
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* pluralizeZoneLabel hackily tries to convert a zone name to a plural noun!
|
|
|
|
*
|
|
|
|
* HACK: It'd be more reliable and more translatable to do this by just
|
|
|
|
* manually creating the plural for each zone. But, ehh! ¯\_ (ツ)_/¯
|
|
|
|
*/
|
|
|
|
function pluralizeZoneLabel(zoneLabel) {
|
|
|
|
if (zoneLabel.endsWith("ss")) {
|
|
|
|
return zoneLabel + "es";
|
|
|
|
} else if (zoneLabel.endsWith("s")) {
|
|
|
|
return zoneLabel;
|
|
|
|
} else {
|
|
|
|
return zoneLabel + "s";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* removeLastWord returns a copy of the text, with the last word and any
|
|
|
|
* preceding space removed.
|
|
|
|
*/
|
|
|
|
function removeLastWord(text) {
|
|
|
|
// This regex matches the full text, and assigns the last word and any
|
|
|
|
// preceding text to subgroup 2, and all preceding text to subgroup 1. If
|
|
|
|
// there's no last word, we'll still match, and the full string will be in
|
|
|
|
// subgroup 1, including any space - no changes made!
|
|
|
|
const match = text.match(/^(.*?)(\s*\S+)?$/);
|
|
|
|
if (!match) {
|
|
|
|
logAndCapture(
|
|
|
|
new Error(
|
|
|
|
`Assertion failure: pattern should match any input text, ` +
|
2023-10-24 16:45:49 -07:00
|
|
|
`but failed to match ${JSON.stringify(text)}`,
|
|
|
|
),
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
return text;
|
|
|
|
}
|
|
|
|
|
|
|
|
return match[1];
|
|
|
|
}
|
|
|
|
|
|
|
|
export default SearchToolbar;
|