diff --git a/src/components/FilterSearch.tsx b/src/components/FilterSearch.tsx index db5f8e81c..e113a4774 100644 --- a/src/components/FilterSearch.tsx +++ b/src/components/FilterSearch.tsx @@ -1,4 +1,4 @@ -import { AutocompleteResult, FieldValueStaticFilter, FilterSearchResponse, SearchParameterField, StaticFilter, useSearchActions, useSearchState } from '@yext/search-headless-react'; +import { AutocompleteResult, FieldValueStaticFilter, FilterSearchResponse, SearchParameterField, SelectableStaticFilter, StaticFilter, useSearchActions, useSearchState } from '@yext/search-headless-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useComposedCssClasses } from '../hooks/useComposedCssClasses'; import { useSynchronizedRequest } from '../hooks/useSynchronizedRequest'; @@ -109,6 +109,13 @@ export function FilterSearch({ const [currentFilter, setCurrentFilter] = useState(); const [filterQuery, setFilterQuery] = useState(); const staticFilters = useSearchState(state => state.filters.static); + const matchingFilters: SelectableStaticFilter[] = useMemo(() => { + return staticFilters?.filter(({ filter, selected }) => + selected + && filter.kind === 'fieldValue' + && searchFields.some(s => s.fieldApiName === filter.fieldId) + ) ?? []; + }, [staticFilters, searchFields]); const [ filterSearchResponse, @@ -123,14 +130,36 @@ export function FilterSearch({ ); useEffect(() => { + if (matchingFilters.length > 1 && !onSelect) { + console.warn('More than one selected static filter found that matches the filter search fields: [' + + searchFields.map(s => s.fieldApiName).join(', ') + + ']. Please update the state to remove the extra filters.' + + ' Picking one filter to display in the input.'); + } + if (currentFilter && staticFilters?.find(f => - isDuplicateStaticFilter(f.filter, currentFilter) && !f.selected + isDuplicateStaticFilter(f.filter, currentFilter) && f.selected )) { + return; + } + + if (matchingFilters.length === 0) { clearFilterSearchResponse(); setCurrentFilter(undefined); setFilterQuery(''); + } else { + setCurrentFilter(matchingFilters[0].filter); + executeFilterSearch(matchingFilters[0].displayName); } - }, [clearFilterSearchResponse, currentFilter, staticFilters]); + }, [ + clearFilterSearchResponse, + currentFilter, + staticFilters, + executeFilterSearch, + onSelect, + matchingFilters, + searchFields + ]); const sections = useMemo(() => { return filterSearchResponse?.sections.filter(section => section.results.length > 0) ?? []; @@ -148,7 +177,7 @@ export function FilterSearch({ if (onSelect) { if (searchOnSelect) { console.warn('Both searchOnSelect and onSelect props were passed to the component.' - + ' Using onSelect instead of searchOnSelect as the latter is deprecated.'); + + ' Using onSelect instead of searchOnSelect as the latter is deprecated.'); } return onSelect({ newFilter, @@ -159,6 +188,12 @@ export function FilterSearch({ }); } + if (matchingFilters.length > 1) { + console.warn('More than one selected static filter found that matches the filter search fields: [' + + searchFields.map(s => s.fieldApiName).join(', ') + + ']. Unselecting all existing matching filters and selecting the new filter.'); + } + matchingFilters.forEach(f => searchActions.setFilterOption({ filter: f.filter, selected: false })); if (currentFilter) { searchActions.setFilterOption({ filter: currentFilter, selected: false }); } @@ -171,7 +206,15 @@ export function FilterSearch({ searchActions.resetFacets(); executeSearch(searchActions); } - }, [currentFilter, searchActions, executeFilterSearch, onSelect, searchOnSelect]); + }, [ + currentFilter, + searchActions, + executeFilterSearch, + onSelect, + searchOnSelect, + matchingFilters, + searchFields + ]); const meetsSubmitCritera = useCallback(index => index >= 0, []); diff --git a/tests/components/FilterSearch.test.tsx b/tests/components/FilterSearch.test.tsx index 56da0cc95..1d1fe5f31 100644 --- a/tests/components/FilterSearch.test.tsx +++ b/tests/components/FilterSearch.test.tsx @@ -24,6 +24,38 @@ const mockedState: Partial = { } }; +const mockedStateWithSingleFilter: Partial = { + ...mockedState, + filters: { + static: [{ + filter: { + kind: 'fieldValue', + fieldId: 'name', + matcher: Matcher.Equals, + value: 'Real Person' + }, + selected: true, + displayName: 'Real Person' + }] + } +}; + +const mockedStateWithMultipleFilters: Partial = { + ...mockedState, + filters: { + static: [...(mockedStateWithSingleFilter.filters?.static ?? []), { + filter: { + kind: 'fieldValue', + fieldId: 'name', + matcher: Matcher.Equals, + value: 'Fake Person' + }, + selected: true, + displayName: 'Fake Person' + }] + } +}; + describe('search with section labels', () => { it('renders the filter search bar, "Filter" label, and default placeholder text', () => { renderFilterSearch({ searchFields: searchFieldsProp, label: 'Filter' }); @@ -179,6 +211,125 @@ describe('search with section labels', () => { }); }); + it('displays name of matching filter in state when no filter is selected from component', async () => { + renderFilterSearch(undefined, mockedStateWithSingleFilter); + const searchBarElement = screen.getByRole('textbox'); + expect(searchBarElement).toHaveValue('Real Person'); + }); + + it('logs a warning when multiple matching filters in state and no current filter selected', async () => { + const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation(); + renderFilterSearch(undefined, mockedStateWithMultipleFilters); + const searchBarElement = screen.getByRole('textbox'); + expect(searchBarElement).toHaveValue('Real Person'); + expect(consoleWarnSpy).toBeCalledWith( + 'More than one selected static filter found that matches the filter search fields: [name].' + + ' Please update the state to remove the extra filters.' + + ' Picking one filter to display in the input.' + ); + }); + + it('does not log a warning for multiple matching filters in state if onSelect is passed', async () => { + const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation(); + const mockedOnSelect = jest.fn(); + renderFilterSearch( + { searchFields: searchFieldsProp, onSelect: mockedOnSelect }, + mockedStateWithMultipleFilters + ); + const searchBarElement = screen.getByRole('textbox'); + expect(searchBarElement).toHaveValue('Real Person'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('unselects single matching filter in state when a new filter is selected and doesn\'t log warning', async () => { + const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation(); + renderFilterSearch(undefined, mockedStateWithSingleFilter); + const executeFilterSearch = jest + .spyOn(SearchHeadless.prototype, 'executeFilterSearch') + .mockResolvedValue(labeledFilterSearchResponse); + const setFilterOption = jest.spyOn(SearchHeadless.prototype, 'setFilterOption'); + const searchBarElement = screen.getByRole('textbox'); + + userEvent.clear(searchBarElement); + userEvent.type(searchBarElement, 'n'); + await waitFor(() => expect(executeFilterSearch).toHaveBeenCalled()); + await waitFor(() => screen.findByText('first name 1')); + userEvent.type(searchBarElement, '{enter}'); + await waitFor(() => { + expect(setFilterOption).toBeCalledWith({ + filter: { + kind: 'fieldValue', + fieldId: 'name', + matcher: Matcher.Equals, + value: 'Real Person' + }, + selected: false + }); + }); + expect(setFilterOption).toBeCalledWith({ + filter: { + kind: 'fieldValue', + fieldId: 'name', + matcher: Matcher.Equals, + value: 'first name 1' + }, + displayName: 'first name 1', + selected: true + }); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('unselects multiple matching filters in state when a new filter is selected and logs warning', async () => { + const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation(); + renderFilterSearch(undefined, mockedStateWithMultipleFilters); + const executeFilterSearch = jest + .spyOn(SearchHeadless.prototype, 'executeFilterSearch') + .mockResolvedValue(labeledFilterSearchResponse); + const setFilterOption = jest.spyOn(SearchHeadless.prototype, 'setFilterOption'); + const searchBarElement = screen.getByRole('textbox'); + + userEvent.clear(searchBarElement); + userEvent.type(searchBarElement, 'n'); + await waitFor(() => expect(executeFilterSearch).toHaveBeenCalled()); + await waitFor(() => screen.findByText('first name 1')); + userEvent.type(searchBarElement, '{enter}'); + await waitFor(() => { + expect(setFilterOption).toBeCalledWith({ + filter: { + kind: 'fieldValue', + fieldId: 'name', + matcher: Matcher.Equals, + value: 'Real Person' + }, + selected: false + }); + }); + expect(setFilterOption).toBeCalledWith({ + filter: { + kind: 'fieldValue', + fieldId: 'name', + matcher: Matcher.Equals, + value: 'Fake Person' + }, + selected: false + }); + expect(setFilterOption).toBeCalledWith({ + filter: { + kind: 'fieldValue', + fieldId: 'name', + matcher: Matcher.Equals, + value: 'first name 1' + }, + displayName: 'first name 1', + selected: true + }); + expect(consoleWarnSpy).toBeCalledWith( + 'More than one selected static filter found that matches the filter search fields: [name].' + + ' Unselecting all existing matching filters and selecting the new filter.' + ); + }); + it('executes onSelect function when a filter is selected', async () => { const mockedOnSelect = jest.fn(); const setFilterOption = jest.spyOn(SearchHeadless.prototype, 'setFilterOption'); @@ -329,7 +480,7 @@ describe('search with section labels', () => { expect(setFilterOption).not.toBeCalled(); expect(mockExecuteSearch).not.toBeCalled(); expect(consoleWarnSpy).toBeCalledWith('Both searchOnSelect and onSelect props were passed to the component.' - + ' Using onSelect instead of searchOnSelect as the latter is deprecated.'); + + ' Using onSelect instead of searchOnSelect as the latter is deprecated.'); }); }); }); @@ -460,8 +611,11 @@ describe('screen reader', () => { }); }); -function renderFilterSearch(props: FilterSearchProps = { searchFields: searchFieldsProp }): RenderResult { - return render( +function renderFilterSearch( + props: FilterSearchProps = { searchFields: searchFieldsProp }, + state = mockedState +): RenderResult { + return render( ); } @@ -490,7 +644,7 @@ it('clears input when old filters are removed', async () => { }; return ( ); @@ -498,7 +652,7 @@ it('clears input when old filters are removed', async () => { render( - + ); const searchBarElement = screen.getByRole('textbox');