Skip to content

Commit a2e8319

Browse files
authored
Update default FilterSearch behavior (#333)
Update the default behavior of `FilterSearch`: - On select, clobber other static filters in State that have a `fieldId` that matches with one of the search fields for `FilterSearch`. If there are multiple filters that were clobbered, log a console warning. - If the static filters state is updated from outside of the component, log a console warning if there are multiple "matching" filters in State and there is no custom `onSelect` prop passed in. - And if there is no filter currently associated with the component, update the input text to the display name of the first "matching" filter that is selected in State. - If there are no matching filters in State, clear out the input and filter search response. Note: We only look for field value filters in State for the "matching" filters. We don't prescribe how compound filters should be handled when comparing one to a search field. In the UCSD use case, a developer would pass in a custom `onSelect` function that would set compound static filters in State. In this case, the `currentFilter` may not be part of the `matchingFilters` array, which is why the concept of a `currentFilter` is still needed. J=SLAP-2432 TEST=auto, manual See that the added Jest tests pass. Spin up the test-site and test that the above situations match the expected behavior with different combinations of filters in State and passing an `onSelect` prop or not.
1 parent 8422231 commit a2e8319

File tree

2 files changed

+207
-10
lines changed

2 files changed

+207
-10
lines changed

src/components/FilterSearch.tsx

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AutocompleteResult, FieldValueStaticFilter, FilterSearchResponse, SearchParameterField, StaticFilter, useSearchActions, useSearchState } from '@yext/search-headless-react';
1+
import { AutocompleteResult, FieldValueStaticFilter, FilterSearchResponse, SearchParameterField, SelectableStaticFilter, StaticFilter, useSearchActions, useSearchState } from '@yext/search-headless-react';
22
import { useCallback, useEffect, useMemo, useState } from 'react';
33
import { useComposedCssClasses } from '../hooks/useComposedCssClasses';
44
import { useSynchronizedRequest } from '../hooks/useSynchronizedRequest';
@@ -109,6 +109,13 @@ export function FilterSearch({
109109
const [currentFilter, setCurrentFilter] = useState<StaticFilter>();
110110
const [filterQuery, setFilterQuery] = useState<string>();
111111
const staticFilters = useSearchState(state => state.filters.static);
112+
const matchingFilters: SelectableStaticFilter[] = useMemo(() => {
113+
return staticFilters?.filter(({ filter, selected }) =>
114+
selected
115+
&& filter.kind === 'fieldValue'
116+
&& searchFields.some(s => s.fieldApiName === filter.fieldId)
117+
) ?? [];
118+
}, [staticFilters, searchFields]);
112119

113120
const [
114121
filterSearchResponse,
@@ -123,14 +130,36 @@ export function FilterSearch({
123130
);
124131

125132
useEffect(() => {
133+
if (matchingFilters.length > 1 && !onSelect) {
134+
console.warn('More than one selected static filter found that matches the filter search fields: ['
135+
+ searchFields.map(s => s.fieldApiName).join(', ')
136+
+ ']. Please update the state to remove the extra filters.'
137+
+ ' Picking one filter to display in the input.');
138+
}
139+
126140
if (currentFilter && staticFilters?.find(f =>
127-
isDuplicateStaticFilter(f.filter, currentFilter) && !f.selected
141+
isDuplicateStaticFilter(f.filter, currentFilter) && f.selected
128142
)) {
143+
return;
144+
}
145+
146+
if (matchingFilters.length === 0) {
129147
clearFilterSearchResponse();
130148
setCurrentFilter(undefined);
131149
setFilterQuery('');
150+
} else {
151+
setCurrentFilter(matchingFilters[0].filter);
152+
executeFilterSearch(matchingFilters[0].displayName);
132153
}
133-
}, [clearFilterSearchResponse, currentFilter, staticFilters]);
154+
}, [
155+
clearFilterSearchResponse,
156+
currentFilter,
157+
staticFilters,
158+
executeFilterSearch,
159+
onSelect,
160+
matchingFilters,
161+
searchFields
162+
]);
134163

135164
const sections = useMemo(() => {
136165
return filterSearchResponse?.sections.filter(section => section.results.length > 0) ?? [];
@@ -148,7 +177,7 @@ export function FilterSearch({
148177
if (onSelect) {
149178
if (searchOnSelect) {
150179
console.warn('Both searchOnSelect and onSelect props were passed to the component.'
151-
+ ' Using onSelect instead of searchOnSelect as the latter is deprecated.');
180+
+ ' Using onSelect instead of searchOnSelect as the latter is deprecated.');
152181
}
153182
return onSelect({
154183
newFilter,
@@ -159,6 +188,12 @@ export function FilterSearch({
159188
});
160189
}
161190

191+
if (matchingFilters.length > 1) {
192+
console.warn('More than one selected static filter found that matches the filter search fields: ['
193+
+ searchFields.map(s => s.fieldApiName).join(', ')
194+
+ ']. Unselecting all existing matching filters and selecting the new filter.');
195+
}
196+
matchingFilters.forEach(f => searchActions.setFilterOption({ filter: f.filter, selected: false }));
162197
if (currentFilter) {
163198
searchActions.setFilterOption({ filter: currentFilter, selected: false });
164199
}
@@ -171,7 +206,15 @@ export function FilterSearch({
171206
searchActions.resetFacets();
172207
executeSearch(searchActions);
173208
}
174-
}, [currentFilter, searchActions, executeFilterSearch, onSelect, searchOnSelect]);
209+
}, [
210+
currentFilter,
211+
searchActions,
212+
executeFilterSearch,
213+
onSelect,
214+
searchOnSelect,
215+
matchingFilters,
216+
searchFields
217+
]);
175218

176219
const meetsSubmitCritera = useCallback(index => index >= 0, []);
177220

tests/components/FilterSearch.test.tsx

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,38 @@ const mockedState: Partial<State> = {
2424
}
2525
};
2626

27+
const mockedStateWithSingleFilter: Partial<State> = {
28+
...mockedState,
29+
filters: {
30+
static: [{
31+
filter: {
32+
kind: 'fieldValue',
33+
fieldId: 'name',
34+
matcher: Matcher.Equals,
35+
value: 'Real Person'
36+
},
37+
selected: true,
38+
displayName: 'Real Person'
39+
}]
40+
}
41+
};
42+
43+
const mockedStateWithMultipleFilters: Partial<State> = {
44+
...mockedState,
45+
filters: {
46+
static: [...(mockedStateWithSingleFilter.filters?.static ?? []), {
47+
filter: {
48+
kind: 'fieldValue',
49+
fieldId: 'name',
50+
matcher: Matcher.Equals,
51+
value: 'Fake Person'
52+
},
53+
selected: true,
54+
displayName: 'Fake Person'
55+
}]
56+
}
57+
};
58+
2759
describe('search with section labels', () => {
2860
it('renders the filter search bar, "Filter" label, and default placeholder text', () => {
2961
renderFilterSearch({ searchFields: searchFieldsProp, label: 'Filter' });
@@ -179,6 +211,125 @@ describe('search with section labels', () => {
179211
});
180212
});
181213

214+
it('displays name of matching filter in state when no filter is selected from component', async () => {
215+
renderFilterSearch(undefined, mockedStateWithSingleFilter);
216+
const searchBarElement = screen.getByRole('textbox');
217+
expect(searchBarElement).toHaveValue('Real Person');
218+
});
219+
220+
it('logs a warning when multiple matching filters in state and no current filter selected', async () => {
221+
const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation();
222+
renderFilterSearch(undefined, mockedStateWithMultipleFilters);
223+
const searchBarElement = screen.getByRole('textbox');
224+
expect(searchBarElement).toHaveValue('Real Person');
225+
expect(consoleWarnSpy).toBeCalledWith(
226+
'More than one selected static filter found that matches the filter search fields: [name].'
227+
+ ' Please update the state to remove the extra filters.'
228+
+ ' Picking one filter to display in the input.'
229+
);
230+
});
231+
232+
it('does not log a warning for multiple matching filters in state if onSelect is passed', async () => {
233+
const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation();
234+
const mockedOnSelect = jest.fn();
235+
renderFilterSearch(
236+
{ searchFields: searchFieldsProp, onSelect: mockedOnSelect },
237+
mockedStateWithMultipleFilters
238+
);
239+
const searchBarElement = screen.getByRole('textbox');
240+
expect(searchBarElement).toHaveValue('Real Person');
241+
expect(consoleWarnSpy).not.toHaveBeenCalled();
242+
});
243+
244+
it('unselects single matching filter in state when a new filter is selected and doesn\'t log warning', async () => {
245+
const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation();
246+
renderFilterSearch(undefined, mockedStateWithSingleFilter);
247+
const executeFilterSearch = jest
248+
.spyOn(SearchHeadless.prototype, 'executeFilterSearch')
249+
.mockResolvedValue(labeledFilterSearchResponse);
250+
const setFilterOption = jest.spyOn(SearchHeadless.prototype, 'setFilterOption');
251+
const searchBarElement = screen.getByRole('textbox');
252+
253+
userEvent.clear(searchBarElement);
254+
userEvent.type(searchBarElement, 'n');
255+
await waitFor(() => expect(executeFilterSearch).toHaveBeenCalled());
256+
await waitFor(() => screen.findByText('first name 1'));
257+
userEvent.type(searchBarElement, '{enter}');
258+
await waitFor(() => {
259+
expect(setFilterOption).toBeCalledWith({
260+
filter: {
261+
kind: 'fieldValue',
262+
fieldId: 'name',
263+
matcher: Matcher.Equals,
264+
value: 'Real Person'
265+
},
266+
selected: false
267+
});
268+
});
269+
expect(setFilterOption).toBeCalledWith({
270+
filter: {
271+
kind: 'fieldValue',
272+
fieldId: 'name',
273+
matcher: Matcher.Equals,
274+
value: 'first name 1'
275+
},
276+
displayName: 'first name 1',
277+
selected: true
278+
});
279+
280+
expect(consoleWarnSpy).not.toHaveBeenCalled();
281+
});
282+
283+
it('unselects multiple matching filters in state when a new filter is selected and logs warning', async () => {
284+
const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation();
285+
renderFilterSearch(undefined, mockedStateWithMultipleFilters);
286+
const executeFilterSearch = jest
287+
.spyOn(SearchHeadless.prototype, 'executeFilterSearch')
288+
.mockResolvedValue(labeledFilterSearchResponse);
289+
const setFilterOption = jest.spyOn(SearchHeadless.prototype, 'setFilterOption');
290+
const searchBarElement = screen.getByRole('textbox');
291+
292+
userEvent.clear(searchBarElement);
293+
userEvent.type(searchBarElement, 'n');
294+
await waitFor(() => expect(executeFilterSearch).toHaveBeenCalled());
295+
await waitFor(() => screen.findByText('first name 1'));
296+
userEvent.type(searchBarElement, '{enter}');
297+
await waitFor(() => {
298+
expect(setFilterOption).toBeCalledWith({
299+
filter: {
300+
kind: 'fieldValue',
301+
fieldId: 'name',
302+
matcher: Matcher.Equals,
303+
value: 'Real Person'
304+
},
305+
selected: false
306+
});
307+
});
308+
expect(setFilterOption).toBeCalledWith({
309+
filter: {
310+
kind: 'fieldValue',
311+
fieldId: 'name',
312+
matcher: Matcher.Equals,
313+
value: 'Fake Person'
314+
},
315+
selected: false
316+
});
317+
expect(setFilterOption).toBeCalledWith({
318+
filter: {
319+
kind: 'fieldValue',
320+
fieldId: 'name',
321+
matcher: Matcher.Equals,
322+
value: 'first name 1'
323+
},
324+
displayName: 'first name 1',
325+
selected: true
326+
});
327+
expect(consoleWarnSpy).toBeCalledWith(
328+
'More than one selected static filter found that matches the filter search fields: [name].'
329+
+ ' Unselecting all existing matching filters and selecting the new filter.'
330+
);
331+
});
332+
182333
it('executes onSelect function when a filter is selected', async () => {
183334
const mockedOnSelect = jest.fn();
184335
const setFilterOption = jest.spyOn(SearchHeadless.prototype, 'setFilterOption');
@@ -329,7 +480,7 @@ describe('search with section labels', () => {
329480
expect(setFilterOption).not.toBeCalled();
330481
expect(mockExecuteSearch).not.toBeCalled();
331482
expect(consoleWarnSpy).toBeCalledWith('Both searchOnSelect and onSelect props were passed to the component.'
332-
+ ' Using onSelect instead of searchOnSelect as the latter is deprecated.');
483+
+ ' Using onSelect instead of searchOnSelect as the latter is deprecated.');
333484
});
334485
});
335486
});
@@ -460,8 +611,11 @@ describe('screen reader', () => {
460611
});
461612
});
462613

463-
function renderFilterSearch(props: FilterSearchProps = { searchFields: searchFieldsProp }): RenderResult {
464-
return render(<SearchHeadlessContext.Provider value={generateMockedHeadless(mockedState)}>
614+
function renderFilterSearch(
615+
props: FilterSearchProps = { searchFields: searchFieldsProp },
616+
state = mockedState
617+
): RenderResult {
618+
return render(<SearchHeadlessContext.Provider value={generateMockedHeadless(state)}>
465619
<FilterSearch {...props} />
466620
</SearchHeadlessContext.Provider>);
467621
}
@@ -490,15 +644,15 @@ it('clears input when old filters are removed', async () => {
490644
};
491645
return (
492646
<button onClick={handleClickDeselectFilter}>
493-
Deselect Filter
647+
Deselect Filter
494648
</button>
495649
);
496650

497651
}
498652

499653
render(<SearchHeadlessContext.Provider value={generateMockedHeadless(mockedState)}>
500654
<FilterSearch searchFields={searchFieldsProp} />
501-
<DeselectFiltersButton/>
655+
<DeselectFiltersButton />
502656
</SearchHeadlessContext.Provider>);
503657

504658
const searchBarElement = screen.getByRole('textbox');

0 commit comments

Comments
 (0)