diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx index d96fa0081beb..1d06e72902a8 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx @@ -51,7 +51,7 @@ function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) { ref={scrollRef} showsHorizontalScrollIndicator={false} data={data} - keyExtractor={(item) => item.key} + keyExtractor={(item) => item.id} renderItem={renderFilterItem} onEndReached={adjustScroll} onEndReachedThreshold={0.75} diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx index e7006ded5896..58131b5eb304 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx @@ -30,7 +30,7 @@ function SearchFiltersBarWideContent({hasErrors, shouldShowFiltersBarLoading, fi return filters.map((item) => ( )); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 53a376153a8e..f6e1aec0d46e 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -6256,6 +6256,8 @@ function getReportFieldTextKey(key: ReportFieldKey): ReportFieldTextKey { type SearchFilter = { key: keyof typeof FILTER_VIEW_MAP; + /** Unique per filter, unlike `key` which is shared by a filter and its negation (e.g. `status` and `statusNot`) */ + id: MappedFilterKey; label: string; value: string | string[]; }; @@ -6328,7 +6330,7 @@ function mapFiltersFormToLabelValueList( if (displayValue && label) { addedGroups.add(syntax); const isDefault = defaultSearchQueryFilterKeys.has(syntax); - (isDefault ? defaultFilters : nonDefaultFilters).push({key: syntax, label: translate(label), value: displayValue, ...mapper?.(syntax, isDefault)}); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: syntax, id: syntax, label: translate(label), value: displayValue, ...mapper?.(syntax, isDefault)}); } continue; } @@ -6344,7 +6346,13 @@ function mapFiltersFormToLabelValueList( addedGroups.add(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX); const isDefault = defaultSearchQueryFilterKeys.has(getReportFieldTextKey(key)); const extra = mapper?.(CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, isDefault); - (isDefault ? defaultFilters : nonDefaultFilters).push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, label: translate('workspace.common.reportField'), value, ...extra}); + (isDefault ? defaultFilters : nonDefaultFilters).push({ + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, + id: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, + label: translate('workspace.common.reportField'), + value, + ...extra, + }); } continue; } @@ -6361,7 +6369,7 @@ function mapFiltersFormToLabelValueList( if (label && value && !(Array.isArray(value) && value.length === 0)) { const isDefault = defaultSearchQueryFilterKeys.has(baseKey); - (isDefault ? defaultFilters : nonDefaultFilters).push({key: baseKey, label, value, ...mapper?.(key, isDefault)}); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: baseKey, id: key, label, value, ...mapper?.(key, isDefault)}); } } diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index b32ecd1f5436..afa8cd4e390f 100644 --- a/src/pages/Search/SearchSavePage.tsx +++ b/src/pages/Search/SearchSavePage.tsx @@ -240,7 +240,7 @@ function SearchSavePage() { appliedFilters.map((filter) => ( {CONST.DOT_SEPARATOR} diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 938e5656a8b9..36fd0a6a1adb 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -15364,6 +15364,22 @@ describe('SearchUIUtils', () => { expect(result.at(0)?.isDefault).toBe(true); expect(result.at(1)?.isDefault).toBe(false); }); + + it('gives a filter and its negation the same key but different ids', () => { + // Given a query that uses the status filter both normally and negated, e.g. `status:approved -status:drafts,paid` + const form = { + [FILTER_KEYS.TYPE]: CONST.SEARCH.DATA_TYPES.EXPENSE, + [FILTER_KEYS.STATUS]: [CONST.SEARCH.STATUS.EXPENSE.APPROVED], + [FILTER_KEYS.STATUS_NOT]: [CONST.SEARCH.STATUS.EXPENSE.DRAFTS, CONST.SEARCH.STATUS.EXPENSE.PAID], + }; + + // When the form is mapped to filter chips + const result = SearchUIUtils.mapFiltersFormToLabelValueList(form, new Set(), new Set(), translateLocal, undefined, localeCompare, convertToDisplayStringWithoutCurrency); + const statusFilters = result.filter((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS); + + // Then both chips share the base key (used to pick the chip's popover), while their ids stay unique so they can be used as React keys + expect(statusFilters.map((filter) => filter.id)).toEqual([FILTER_KEYS.STATUS, FILTER_KEYS.STATUS_NOT]); + }); }); }); diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx index 12992acbccf4..ae56ee5f76a3 100644 --- a/tests/unit/pages/Search/SearchSavePageTest.tsx +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -75,8 +75,8 @@ it.each([[['12']], [['12', '23']], [['123']]])('renders canonical card selection }); it('renders scalar feed and tax values without calling array display hooks', () => { jest.spyOn(SearchUIUtils, 'mapFiltersFormToLabelValueList').mockReturnValueOnce([ - {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, label: 'Feed', value: 'feed-scalar'}, - {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, label: 'Tax', value: 'tax-scalar'}, + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, id: CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, label: 'Feed', value: 'feed-scalar'}, + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, id: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, label: 'Tax', value: 'tax-scalar'}, ]); const output = JSON.stringify(render().toJSON()); expect(['feed-scalar', 'tax-scalar'].every((text) => output.includes(text))).toBe(true);