Skip to content

Commit

Permalink
Merge pull request #10389 from DestinyItemManager/loadout-search
Browse files Browse the repository at this point in the history
Loadout Search
  • Loading branch information
bhollis authored May 9, 2024
2 parents c65aa1b + 01983b2 commit cf3c363
Show file tree
Hide file tree
Showing 24 changed files with 419 additions and 118 deletions.
6 changes: 6 additions & 0 deletions config/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@
"FileUpload": {
"Instructions": "Click or drag files"
},
"LoadoutFilter": {
"Name": "Shows loadouts whose name matches (exactname:) or partially matches (name:) the filter text. Search for entire phrases using quotes.",
"Notes": "Search for loadouts by their notes field.",
"PartialMatch": "Shows loadouts where their name or notes has a partial match to the filter text. Search for entire phrases using quotes.",
"Season": "Shows loadouts by which season of Destiny 2 they were last modified in."
},
"Filter": {
"Adept": "\\(Adept\\)",
"AmmoType": "Shows items based on their ammo type.",
Expand Down
8 changes: 4 additions & 4 deletions src/app/dim-api/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { convertDimLoadoutToApiLoadout } from 'app/loadout-drawer/loadout-type-c
import { recentSearchComparator } from 'app/search/autocomplete';
import { CUSTOM_TOTAL_STAT_HASH } from 'app/search/d2-known-values';
import { FilterContext } from 'app/search/filter-types';
import { buildFiltersMap } from 'app/search/search-config';
import { buildItemFiltersMap } from 'app/search/search-config';
import { parseAndValidateQuery } from 'app/search/search-utils';
import { count, uniqBy } from 'app/utils/collections';
import { emptyArray } from 'app/utils/empty';
Expand Down Expand Up @@ -1127,7 +1127,7 @@ function trackTriumph(
function searchUsed(draft: Draft<DimApiState>, account: DestinyAccount, query: string) {
const destinyVersion = account.destinyVersion;
// Note: memoized
const filtersMap = buildFiltersMap(destinyVersion);
const filtersMap = buildItemFiltersMap(destinyVersion);

// Canonicalize the query so we always save it the same way
const { canonical, saveInHistory } = parseAndValidateQuery(query, filtersMap, {
Expand Down Expand Up @@ -1188,7 +1188,7 @@ function saveSearch(
) {
const destinyVersion = account.destinyVersion;
// Note: memoized
const filtersMap = buildFiltersMap(destinyVersion);
const filtersMap = buildItemFiltersMap(destinyVersion);

// Canonicalize the query so we always save it the same way
const { canonical, saveable } = parseAndValidateQuery(query, filtersMap, {
Expand Down Expand Up @@ -1260,7 +1260,7 @@ function cleanupInvalidSearches(draft: Draft<DimApiState>, account: DestinyAccou
}

// Note: memoized
const filtersMap = buildFiltersMap(account.destinyVersion);
const filtersMap = buildItemFiltersMap(account.destinyVersion);
for (const search of draft.searches[account.destinyVersion]) {
if (search.saved || search.usageCount <= 0) {
continue;
Expand Down
7 changes: 7 additions & 0 deletions src/app/loadout-drawer/loadout-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
DestinyItemSubType,
DestinyItemType,
DestinyLoadoutItemComponent,
DestinySeasonDefinition,
} from 'bungie-api-ts/destiny2';
import deprecatedMods from 'data/d2/deprecated-mods.json';
import { BucketHashes, SocketCategoryHashes } from 'data/d2/generated-enums';
Expand Down Expand Up @@ -935,3 +936,9 @@ export function filterLoadoutToAllowedItems(
}
});
}

export function getLoadoutSeason(loadout: Loadout, seasons: DestinySeasonDefinition[]) {
return seasons.find(
(s) => new Date(s.startDate!).getTime() <= (loadout.lastUpdatedAt ?? Date.now()),
);
}
24 changes: 15 additions & 9 deletions src/app/loadout/Loadouts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ import {
} from 'app/loadout-analyzer/hooks';
import { editLoadout } from 'app/loadout-drawer/loadout-events';
import { InGameLoadout, Loadout } from 'app/loadout-drawer/loadout-types';
import { newLoadout, newLoadoutFromEquipped } from 'app/loadout-drawer/loadout-utils';
import {
getLoadoutSeason,
newLoadout,
newLoadoutFromEquipped,
} from 'app/loadout-drawer/loadout-utils';
import { loadoutsForClassTypeSelector } from 'app/loadout-drawer/loadouts-selector';
import { useD2Definitions } from 'app/manifest/selectors';
import { loadoutFilterFactorySelector } from 'app/search/loadouts/loadout-search-filter';
import { useSetting } from 'app/settings/hooks';
import { AppIcon, addIcon, faCalculator, uploadIcon } from 'app/shell/icons';
import { querySelector, useIsPhonePortrait } from 'app/shell/selectors';
Expand Down Expand Up @@ -123,7 +128,14 @@ function Loadouts({ account }: { account: DestinyAccount }) {

const filteringLoadouts = Boolean(query || hasSelectedFilters);

const loadouts = searchAndSortLoadoutsByQuery(filteredLoadouts, query, language, loadoutSort);
const loadoutFilterFactory = useSelector(loadoutFilterFactorySelector);
const loadouts = searchAndSortLoadoutsByQuery(
filteredLoadouts,
loadoutFilterFactory,
query,
language,
loadoutSort,
);
if (!filteringLoadouts) {
loadouts.unshift(currentLoadout);
}
Expand Down Expand Up @@ -297,13 +309,7 @@ function useAddSeasonHeaders(loadouts: Loadout[], loadoutSort: LoadoutSort) {
.sort((a, b) => b.seasonNumber - a.seasonNumber)
.filter((s) => s.startDate);

const grouped = Map.groupBy(
loadouts,
(loadout) =>
seasons.find(
(s) => new Date(s.startDate!).getTime() <= (loadout.lastUpdatedAt ?? Date.now()),
)!,
);
const grouped = Map.groupBy(loadouts, (loadout) => getLoadoutSeason(loadout, seasons)!);

loadoutRows = [...grouped.entries()].flatMap(([season, loadouts]) => [season, ...loadouts]);
}
Expand Down
38 changes: 12 additions & 26 deletions src/app/loadout/loadout-menu/LoadoutPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { previousLoadoutSelector } from 'app/loadout-drawer/selectors';
import { manifestSelector, useDefinitions } from 'app/manifest/selectors';
import { showMaterialCount } from 'app/material-counts/MaterialCountsWrappers';
import { showNotification } from 'app/notifications/notifications';
import SearchBar from 'app/search/SearchBar';
import { loadoutFilterFactorySelector } from 'app/search/loadouts/loadout-search-filter';
import { filteredItemsSelector, searchFilterSelector } from 'app/search/search-filter';
import {
AppIcon,
Expand All @@ -51,7 +53,6 @@ import { querySelector, useIsPhonePortrait } from 'app/shell/selectors';
import { useThunkDispatch } from 'app/store/thunk-dispatch';
import { RootState, ThunkResult } from 'app/store/types';
import { queueAction } from 'app/utils/action-queue';
import { isiOSBrowser } from 'app/utils/browsers';
import { emptyArray } from 'app/utils/empty';
import { errorMessage } from 'app/utils/errors';
import { DestinyClass } from 'bungie-api-ts/destiny2';
Expand Down Expand Up @@ -143,47 +144,32 @@ export default function LoadoutPopup({
dimStore,
{ className: styles.filterPills, darkBackground: true },
);

const loadoutFilterFactory = useSelector(loadoutFilterFactorySelector);
const filteredLoadouts = searchAndSortLoadoutsByQuery(
pillFilteredLoadouts,
loadoutFilterFactory,
loadoutQuery,
language,
loadoutSort,
);

const blockPropagation = (e: React.MouseEvent) => e.stopPropagation();

// On iOS at least, focusing the keyboard pushes the content off the screen
const nativeAutoFocus = !isPhonePortrait && !isiOSBrowser();

const filteringLoadouts = loadoutQuery.length > 0 || hasSelectedFilters;

const handleEscape = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
if (loadoutQuery === '') {
onClick?.();
} else {
setLoadoutQuery('');
}
e.preventDefault();
e.stopPropagation();
}
};

return (
<div className={styles.content} onClick={onClick} role="menu">
{totalLoadouts >= 10 && (
<form className={styles.filterInput}>
<AppIcon icon={searchIcon} className="search-bar-icon" />
<input
type="text"
autoFocus={nativeAutoFocus}
<div onClick={blockPropagation}>
<SearchBar
className={styles.filterInput}
placeholder={t('Header.FilterHelpLoadouts')}
onClick={blockPropagation}
value={loadoutQuery}
onChange={(e) => setLoadoutQuery(e.target.value)}
onKeyDown={handleEscape}
onQueryChanged={setLoadoutQuery}
loadouts
instant
/>
</form>
</div>
)}

{filterPills}
Expand Down
10 changes: 5 additions & 5 deletions src/app/loadout/loadout-ui/menu-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { Loadout } from 'app/loadout-drawer/loadout-types';
import { isArmorModsOnly, isFashionOnly } from 'app/loadout-drawer/loadout-utils';
import { useD2Definitions } from 'app/manifest/selectors';
import { DEFAULT_ORNAMENTS } from 'app/search/d2-known-values';
import { ItemFilter } from 'app/search/filter-types';
import { faCheckCircle, refreshIcon } from 'app/shell/icons';
import AppIcon from 'app/shell/icons/AppIcon';
import { compareBy } from 'app/utils/comparators';
import { emptyArray } from 'app/utils/empty';
import { localizedIncludes, localizedSorter } from 'app/utils/intl';
import { localizedSorter } from 'app/utils/intl';
import clsx from 'clsx';
import modificationsIcon from 'destiny-icons/general/modifications.svg';
import _ from 'lodash';
Expand Down Expand Up @@ -270,16 +271,15 @@ function AnalysisProgress({
*/
export function searchAndSortLoadoutsByQuery(
loadouts: Loadout[],
loadoutFilterFactory: (query: string) => ItemFilter<Loadout>,
query: string,
language: DimLanguage,
loadoutSort: LoadoutSort,
) {
let filteredLoadouts: Loadout[];
if (query.length) {
const includes = localizedIncludes(language, query);
filteredLoadouts = loadouts.filter(
(loadout) => includes(loadout.name) || (loadout.notes && includes(loadout.notes)),
);
const loadoutFilter = loadoutFilterFactory(query);
filteredLoadouts = loadouts.filter(loadoutFilter);
} else {
filteredLoadouts = [...loadouts];
}
Expand Down
52 changes: 43 additions & 9 deletions src/app/search/FilterHelp.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import StaticPage from 'app/dim-ui/StaticPage';
import { t } from 'app/i18next-t';
import { DimItem } from 'app/inventory/item-types';
import { Loadout } from 'app/loadout-drawer/loadout-types';
import { toggleSearchQueryComponent } from 'app/shell/actions';
import { RootState } from 'app/store/types';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styles from './FilterHelp.m.scss';
import { SearchInput } from './SearchInput';
import { FilterDefinition } from './filter-types';
import { searchConfigSelector } from './search-config';
import { generateGroupedSuggestionsForFilter } from './suggestions-generation';
import { FilterContext, FilterDefinition, SuggestionsContext } from './filter-types';
import { LoadoutFilterContext, LoadoutSuggestionsContext } from './loadouts/loadout-filter-types';
import {
loadoutSearchConfigSelector,
loadoutSuggestionsContextSelector,
} from './loadouts/loadout-search-filter';
import { SearchConfig, searchConfigSelector } from './search-config';
import {
generateGroupedSuggestionsForFilter,
suggestionsContextSelector,
} from './suggestions-generation';

function keywordsString(keywords: string | string[]) {
if (Array.isArray(keywords)) {
Expand All @@ -16,8 +27,15 @@ function keywordsString(keywords: string | string[]) {
return keywords;
}

export default function FilterHelp() {
const searchConfig = useSelector(searchConfigSelector).filtersMap;
export default function FilterHelp({ loadouts }: { loadouts?: boolean }) {
const searchConfig = useSelector<
RootState,
| SearchConfig<DimItem, FilterContext, SuggestionsContext>
| SearchConfig<Loadout, LoadoutFilterContext, LoadoutSuggestionsContext>
>(loadouts ? loadoutSearchConfigSelector : searchConfigSelector).filtersMap;
const suggestionContext = useSelector(
loadouts ? loadoutSuggestionsContextSelector : suggestionsContextSelector,
);
const [search, setSearch] = useState('');

const searchLower = search.toLowerCase();
Expand Down Expand Up @@ -63,7 +81,11 @@ export default function FilterHelp() {
</thead>
<tbody>
{filters.map((filter) => (
<FilterExplanation key={keywordsString(filter.keywords)} filter={filter} />
<FilterExplanation
key={keywordsString(filter.keywords)}
filter={filter}
suggestionContext={suggestionContext}
/>
))}
</tbody>
</table>
Expand All @@ -72,11 +94,23 @@ export default function FilterHelp() {
);
}

function FilterExplanation({ filter }: { filter: FilterDefinition }) {
function FilterExplanation({
filter,
suggestionContext,
}: {
filter:
| FilterDefinition<Loadout, LoadoutFilterContext, LoadoutSuggestionsContext>
| FilterDefinition;
suggestionContext: LoadoutSuggestionsContext | SuggestionsContext;
}) {
const dispatch = useDispatch();
const suggestions = Array.from(
new Set([...generateGroupedSuggestionsForFilter(filter, true, {})]),
let suggestions = Array.from(
new Set([...generateGroupedSuggestionsForFilter(filter, suggestionContext, true)]),
);
if (filter.format === 'freeform' || filter.format?.includes('freeform')) {
suggestions = suggestions.slice(0, 5);
}

const localDesc: string = Array.isArray(filter.description)
? t(...filter.description)
: t(filter.description);
Expand Down
31 changes: 26 additions & 5 deletions src/app/search/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Search } from '@destinyitemmanager/dim-api-types';
import ArmorySheet from 'app/armory/ArmorySheet';
import { saveSearch, searchDeleted, searchUsed } from 'app/dim-api/basic-actions';
import { languageSelector, recentSearchesSelector } from 'app/dim-api/selectors';
Expand All @@ -13,6 +14,7 @@ import { toggleSearchResults } from 'app/shell/actions';
import { useIsPhonePortrait } from 'app/shell/selectors';
import { useThunkDispatch } from 'app/store/thunk-dispatch';
import { isiOSBrowser } from 'app/utils/browsers';
import { emptyArray } from 'app/utils/empty';
import { Portal } from 'app/utils/temp-container';
import clsx from 'clsx';
import { UseComboboxState, UseComboboxStateChangeOptions, useCombobox } from 'downshift';
Expand Down Expand Up @@ -50,6 +52,10 @@ import HighlightedText from './HighlightedText';
import styles from './SearchBar.m.scss';
import { buildArmoryIndex } from './armory-search';
import createAutocompleter, { SearchItem, SearchItemType } from './autocomplete';
import {
loadoutSearchConfigSelector,
validateLoadoutQuerySelector,
} from './loadouts/loadout-search-filter';
import { canonicalizeQuery, parseQuery } from './query-parser';
import { searchConfigSelector } from './search-config';
import { validateQuerySelector } from './search-filter';
Expand Down Expand Up @@ -77,6 +83,12 @@ const autoCompleterSelector = createSelector(
createAutocompleter,
);

const loadoutAutoCompleterSelector = createSelector(
loadoutSearchConfigSelector,
() => undefined,
createAutocompleter,
);

const LazyFilterHelp = lazy(() => import(/* webpackChunkName: "filter-help" */ './FilterHelp'));

const RowContents = memo(({ item }: { item: SearchItem }) => {
Expand Down Expand Up @@ -195,6 +207,7 @@ function SearchBar(
onClear,
className,
menu,
loadouts,
}: {
/** Placeholder text when nothing has been typed */
placeholder: string;
Expand All @@ -208,6 +221,8 @@ function SearchBar(
children?: React.ReactNode;
/** An optional menu of actions that can be executed on the search. Always shown. */
menu?: React.ReactNode;
/** Whether this search bar applies to loadouts rather than items. */
loadouts?: boolean;
instant?: boolean;
className?: string;
/** Fired whenever the query changes (already debounced) */
Expand All @@ -219,9 +234,15 @@ function SearchBar(
) {
const dispatch = useThunkDispatch();
const isPhonePortrait = useIsPhonePortrait();
const recentSearches = useSelector(recentSearchesSelector);
const autocompleter = useSelector(autoCompleterSelector);
const validateQuery = useSelector(validateQuerySelector);
const recentSearches = useSelector(
loadouts ? () => emptyArray<Search>() : recentSearchesSelector,
);
const autocompleter = useSelector(
loadouts ? loadoutAutoCompleterSelector : autoCompleterSelector,
);
const validateQuery = useSelector(
loadouts ? validateLoadoutQuerySelector : validateQuerySelector,
);

// On iOS at least, focusing the keyboard pushes the content off the screen
const autoFocus = !mainSearchBar && !isPhonePortrait && !isiOSBrowser();
Expand All @@ -248,7 +269,7 @@ function SearchBar(

const lastBlurQuery = useRef<string>();
const onBlur = () => {
if (valid && liveQuery && liveQuery !== lastBlurQuery.current) {
if (!loadouts && valid && liveQuery && liveQuery !== lastBlurQuery.current) {
// save this to the recent searches only on blur
// we use the ref to only fire if the query changed since the last blur
dispatch(searchUsed(liveQuery));
Expand Down Expand Up @@ -567,7 +588,7 @@ function SearchBar(
freezeInitialHeight
sheetClassName={styles.filterHelp}
>
<LazyFilterHelp />
<LazyFilterHelp loadouts={loadouts} />
</Sheet>
</Suspense>
)}
Expand Down
Loading

0 comments on commit cf3c363

Please sign in to comment.