diff --git a/src/app/WardrobePage/ItemsAndSearchPanels.js b/src/app/WardrobePage/ItemsAndSearchPanels.js index 00dedd8..34cdd71 100644 --- a/src/app/WardrobePage/ItemsAndSearchPanels.js +++ b/src/app/WardrobePage/ItemsAndSearchPanels.js @@ -41,7 +41,9 @@ function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) { onChange={onChange} /> - {searchQuery.value || searchQuery.filterToZoneLabel ? ( + {searchQuery.value || + searchQuery.filterToItemKind || + searchQuery.filterToZoneLabel ? ( { // This is called each time the query completes, including on @@ -434,7 +444,11 @@ function useScrollTracker(scrollContainerRef, threshold, onScrolledToBottom) { * JS comparison. */ function serializeQuery(query) { - return `${JSON.stringify([query.value, query.filterToZoneLabel])}`; + return `${JSON.stringify([ + query.value, + query.filterToItemKind, + query.filterToZoneLabel, + ])}`; } export default SearchPanel; diff --git a/src/app/WardrobePage/SearchToolbar.js b/src/app/WardrobePage/SearchToolbar.js index a14e35a..e52bf47 100644 --- a/src/app/WardrobePage/SearchToolbar.js +++ b/src/app/WardrobePage/SearchToolbar.js @@ -61,7 +61,7 @@ function SearchToolbar({ const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300"); const renderSuggestion = React.useCallback( - (zoneLabel, { isHighlighted }) => ( + ({ text }, { isHighlighted }) => ( - {zoneLabel} + {text} ), [suggestionBgColor, highlightedBgColor] @@ -101,10 +101,12 @@ function SearchToolbar({ [] ); - // When we change the filter zone, clear out the suggestions. + // When we change the query filters, clear out the suggestions. React.useEffect(() => { setSuggestions([]); - }, [query.filterToZoneLabel]); + }, [query.filterToItemKind, query.filterToZoneLabel]); + + const queryFilterText = getQueryFilterText(query); const focusBorderColor = useColorModeValue("green.600", "green.400"); @@ -112,7 +114,7 @@ function SearchToolbar({ - setSuggestions(getSuggestions(value, zoneLabels)) + setSuggestions(getSuggestions(value, query, zoneLabels)) } onSuggestionsClearRequested={() => setSuggestions([])} onSuggestionSelected={(e, { suggestion }) => { @@ -120,20 +122,20 @@ function SearchToolbar({ onChange({ ...query, value: valueWithoutLastWord, - filterToZoneLabel: suggestion, + filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel, + filterToItemKind: suggestion.itemKind || query.filterToItemKind, }); }} getSuggestionValue={(zl) => zl} - shouldRenderSuggestions={() => query.filterToZoneLabel == null} highlightFirstSuggestion={true} renderSuggestion={renderSuggestion} renderSuggestionsContainer={renderSuggestionsContainer} renderInputComponent={(props) => ( - {query.filterToZoneLabel ? ( + {queryFilterText ? ( - {query.filterToZoneLabel} + {queryFilterText} ) : ( @@ -141,7 +143,7 @@ function SearchToolbar({ )} - {(query.value || query.filterToZoneLabel) && ( + {(query.value || queryFilterText) && ( } @@ -172,13 +174,11 @@ function SearchToolbar({ // for the InputLeftAddon, so the styles aren't updating... // Hard override! className: css` - padding-left: ${query.filterToZoneLabel - ? "1rem" - : "2.5rem"} !important; - border-bottom-left-radius: ${query.filterToZoneLabel + padding-left: ${queryFilterText ? "1rem" : "2.5rem"} !important; + border-bottom-left-radius: ${queryFilterText ? "0" : "0.25rem"} !important; - border-top-left-radius: ${query.filterToZoneLabel + border-top-left-radius: ${queryFilterText ? "0" : "0.25rem"} !important; `, @@ -204,7 +204,11 @@ function SearchToolbar({ } onMoveFocusDownToResults(e); } else if (e.key === "Backspace" && e.target.selectionStart === 0) { - onChange({ ...query, filterToZoneLabel: null }); + onChange({ + ...query, + filterToItemKind: null, + filterToZoneLabel: null, + }); } }, }} @@ -212,17 +216,56 @@ function SearchToolbar({ ); } -function getSuggestions(value, zoneLabels) { +function getSuggestions(value, query, zoneLabels) { const words = value.split(/\s+/); const lastWord = words[words.length - 1]; if (lastWord.length < 2) { return []; } - const matchingZoneLabels = zoneLabels.filter((zl) => - zl.toLowerCase().includes(lastWord.toLowerCase()) - ); - return matchingZoneLabels; + const suggestions = []; + + if (query.filterToItemKind == null) { + if (wordMatches("NC", lastWord) || wordMatches("Neocash", lastWord)) { + suggestions.push({ itemKind: "NC", text: "Neocash items" }); + } + + if (wordMatches("NP", lastWord) || wordMatches("Neopoints", lastWord)) { + suggestions.push({ itemKind: "NP", text: "Neopoint items" }); + } + + if (wordMatches("PB", lastWord) || wordMatches("Paintbrush", lastWord)) { + suggestions.push({ itemKind: "PB", text: "Paintbrush items" }); + } + } + + if (query.filterToZoneLabel == null) { + for (const zoneLabel of zoneLabels) { + if (wordMatches(zoneLabel, lastWord)) { + suggestions.push({ zoneLabel, text: 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(query.filterToZoneLabel); + } + + return textWords.join(" "); } export default SearchToolbar; diff --git a/src/server/loaders.js b/src/server/loaders.js index e3cc6ab..9b7aeaa 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -219,12 +219,20 @@ const buildItemSearchLoader = (db, loaders) => return responses; }); +const itemSearchKindConditions = { + // 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. + NC: `rarity_index IN (0, 500)`, + NP: `rarity_index NOT IN (0, 500) AND description NOT 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 buildItemSearchToFitLoader = (db, loaders) => new DataLoader(async (queryAndBodyIdPairs) => { // This isn't actually optimized as a batch query, we're just using a // DataLoader API consistency with our other loaders! const queryPromises = queryAndBodyIdPairs.map( - async ({ query, bodyId, zoneIds = [], offset, limit }) => { + async ({ query, bodyId, itemKind, zoneIds = [], offset, limit }) => { const actualOffset = offset || 0; const actualLimit = Math.min(limit || 30, 30); @@ -235,6 +243,7 @@ const buildItemSearchToFitLoader = (db, loaders) => 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(", ")})` @@ -245,9 +254,9 @@ const buildItemSearchToFitLoader = (db, loaders) => 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 + WHERE ${matcherPlaceholders} AND t.locale = "en" AND (swf_assets.body_id = ? OR swf_assets.body_id = 0) AND - ${zoneIdsPlaceholder} + ${zoneIdsPlaceholder} AND ${itemKindCondition} ORDER BY t.name LIMIT ? OFFSET ?`, [ diff --git a/src/server/types/Item.js b/src/server/types/Item.js index a15098c..e72a8ea 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -69,6 +69,12 @@ const typeDefs = gql` restrictedZones: [Zone!]! } + enum ItemKindSearchFilter { + NC + NP + PB + } + type ItemSearchResult { query: String! zones: [Zone!]! @@ -91,9 +97,10 @@ const typeDefs = gql` itemSearch(query: String!): ItemSearchResult! itemSearchToFit( query: String! + itemKind: ItemKindSearchFilter + zoneIds: [ID!] speciesId: ID! colorId: ID! - zoneIds: [ID!] offset: Int limit: Int ): ItemSearchResult! @@ -295,7 +302,7 @@ const resolvers = { }, itemSearchToFit: async ( _, - { query, speciesId, colorId, zoneIds = [], offset, limit }, + { query, speciesId, colorId, itemKind, zoneIds = [], offset, limit }, { petTypeBySpeciesAndColorLoader, itemSearchToFitLoader } ) => { const petType = await petTypeBySpeciesAndColorLoader.load({ @@ -305,8 +312,9 @@ const resolvers = { const { bodyId } = petType; const items = await itemSearchToFitLoader.load({ query: query.trim(), - bodyId, + itemKind, zoneIds, + bodyId, offset, limit, });