Matchu
81b2a2b4a2
We add jsbuilding-rails to get esbuild running in the app, and then we copy-paste the files we need from impress-2020 into here! I stopped at the point where it was building successfully, but it's not running correctly: it's not sure about `process.env` in `next`, and I think the right next step is to delete the NextJS deps altogether and use React Router instead.
479 lines
14 KiB
JavaScript
479 lines
14 KiB
JavaScript
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>
|
|
),
|
|
[suggestionBgColor, highlightedBgColor]
|
|
);
|
|
|
|
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;
|
|
}
|
|
`
|
|
)}
|
|
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
|
|
>
|
|
{children}
|
|
{!children && advancedSearchIsOpen && (
|
|
<Box
|
|
padding="4"
|
|
fontSize="sm"
|
|
fontStyle="italic"
|
|
textAlign="center"
|
|
>
|
|
No more filters available!
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</ClassNames>
|
|
);
|
|
},
|
|
[advancedSearchIsOpen, suggestionsPlacement]
|
|
);
|
|
|
|
// 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(
|
|
getSuggestions(value, query, zoneLabels, isLoggedIn)
|
|
);
|
|
}
|
|
}}
|
|
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,
|
|
{ showAll = false } = {}
|
|
) {
|
|
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, ` +
|
|
`but failed to match ${JSON.stringify(text)}`
|
|
)
|
|
);
|
|
return text;
|
|
}
|
|
|
|
return match[1];
|
|
}
|
|
|
|
export default SearchToolbar;
|