diff --git a/core/i18n/locales/de.json b/core/i18n/locales/de.json index 7e02fe0d987734bc6acf912a908217d409190b33..a633fbe013c48f975dcdc58662382067bc0993bc 100644 --- a/core/i18n/locales/de.json +++ b/core/i18n/locales/de.json @@ -817,7 +817,6 @@ "search.section.users.loadMore": "Zeige alle Personen", "search.section.users.title": "Personen", "search.title": "Suche", - "search.typesense.facets.all": "All", "search.typesense.facets.profile": "Nutzer", "search.typesense.facets.space": "Spaces", "search.typesense.facets.volunteering": "Volunteering", diff --git a/core/i18n/locales/en.json b/core/i18n/locales/en.json index 4cde38309e674dd7f26b29f631a47728a8942b98..62f79815c87df9296c05d478a87462c2593f8354 100644 --- a/core/i18n/locales/en.json +++ b/core/i18n/locales/en.json @@ -816,7 +816,6 @@ "search.section.users.loadMore": "See all people", "search.section.users.title": "People", "search.title": "Search", - "search.typesense.facets.all": "All", "search.typesense.facets.profile": "User", "search.typesense.facets.space": "Spaces", "search.typesense.facets.volunteering": "Volunteering", diff --git a/core/screens/search/typesense/SearchFacetsChips.tsx b/core/screens/search/typesense/SearchFacetsChips.tsx index 2a8b533992d14ae1f6abfa8d156e12146dce3df5..9752920033edb359c87ffa1c1954ffd38152ae8e 100644 --- a/core/screens/search/typesense/SearchFacetsChips.tsx +++ b/core/screens/search/typesense/SearchFacetsChips.tsx @@ -1,20 +1,24 @@ import { dimensions } from '@holi/ui/styles/globalVars' -import { Selector } from 'holi-bricks/components/selectors' -import { SelectableChip } from 'holi-bricks/components/selectors/Selectable' import { useStyles } from 'holi-bricks/hooks' import { createStyleSheet } from 'holi-bricks/utils' import React, { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useClearRefinements, useRefinementList } from 'react-instantsearch-core' import { ScrollView, View } from 'react-native' - -const KEY_ALL = 'all' +import { Selector } from 'holi-bricks/components/selectors' +import { SelectableChip } from 'holi-bricks/components/selectors/Selectable' interface SearchFacetsChipsProps { initialFacet?: string onFacetChange?: (filter?: string) => void } +const FACET_ORDERING_POSITIONS = new Map([ + ['volunteering', 1], + ['space', 2], + ['profile', 3], +]) + export const SearchFacetsChips = ({ initialFacet, onFacetChange }: SearchFacetsChipsProps) => { const { styles } = useStyles(stylesheet) const { t } = useTranslation() @@ -24,25 +28,28 @@ export const SearchFacetsChips = ({ initialFacet, onFacetChange }: SearchFacetsC sortBy: ['count'], }) - const initiallySelectedOptions = [initialFacet ? initialFacet : KEY_ALL] - const selected = items.filter((item) => item.isRefined).map((item) => item.value) + const initiallySelectedOptions = [initialFacet ? initialFacet : 'volunteering'] + + const refinedItems = items.filter((item) => item.isRefined).map((item) => item.value) const onSelect = (value: string) => { - const wasSelected = selected.includes(value) - clearRefinement() - if (value !== KEY_ALL && !wasSelected) { - refine(value) + if (refinedItems.includes(value)) { + return } - onFacetChange?.(value === KEY_ALL ? undefined : value) + clearRefinement() + refine(value) + onFacetChange?.(value) } useEffect(() => { - if (initialFacet) { - onSelect(initialFacet) - } + onSelect(initiallySelectedOptions[0]) // Only trigger on initial render // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + const orderedItems = items + .map((item) => ({ item, pos: FACET_ORDERING_POSITIONS.get(item.value) || Number.MAX_SAFE_INTEGER })) + .sort((a, b) => a.pos - b.pos) + return ( <ScrollView horizontal={true} @@ -51,20 +58,11 @@ export const SearchFacetsChips = ({ initialFacet, onFacetChange }: SearchFacetsC > <Selector onSelect={onSelect} initiallySelectedOptions={initiallySelectedOptions} inputType="radio"> <View style={styles.layout}> - <SelectableChip - id={KEY_ALL} - color="white" - size="md" - selected={!selected.length} - aria-label={t('search.typesense.facets.all')} - > - {t('search.typesense.facets.all')} - </SelectableChip> - {items.map((item) => { + {orderedItems.map(({ item }) => { const label = t(`search.typesense.facets.${item.label}`) return ( <SelectableChip id={item.value} key={item.value} color="white" size="md" aria-label={label}> - {`${label} ยท ${item.count}`} + {label} </SelectableChip> ) })} diff --git a/core/screens/search/typesense/__tests__/SearchFacetsChips.test.tsx b/core/screens/search/typesense/__tests__/SearchFacetsChips.test.tsx index f9a480561c3e7effc5f8c34b6eb5a3f9d5486515..199e521545e516d974f08c98bde6da5b41e724b1 100644 --- a/core/screens/search/typesense/__tests__/SearchFacetsChips.test.tsx +++ b/core/screens/search/typesense/__tests__/SearchFacetsChips.test.tsx @@ -14,14 +14,14 @@ jest.mock('react-instantsearch-core', () => ({ describe('SearchFacetsChips', () => { const clearRefinement = jest.fn() - const refine = jest.fn() + const mockRefine = jest.fn() const mockRefinementList = ({ items = facets }: Partial<ReturnType<typeof useRefinementList>> = {}) => { mockUseRefinementList.mockReturnValue({ items, hasExhaustiveItems: false, createURL: jest.fn(), - refine, + refine: mockRefine, sendEvent: jest.fn(), searchForItems: jest.fn(), isFromSearch: false, @@ -45,7 +45,7 @@ describe('SearchFacetsChips', () => { it('should render available facets', async () => { render(<SearchFacetsChips />) - const expectedLabels = ['all'].concat(facets.map((facet) => facet.label)) + const expectedLabels = facets.map((facet) => facet.label) expectedLabels.forEach((label: string) => { expect(screen.getByLabelText(`search.typesense.facets.${label}`)).toBeOnTheScreen() }) @@ -58,7 +58,7 @@ describe('SearchFacetsChips', () => { await user.press(screen.getByLabelText('search.typesense.facets.profile')) - expect(refine).toHaveBeenCalledWith('profile') + expect(mockRefine).toHaveBeenCalledWith('profile') }) it('should clear previous refinement on facet press', async () => { @@ -82,37 +82,21 @@ describe('SearchFacetsChips', () => { expect(onFacetChange).toHaveBeenCalledWith('profile') }) - it('should toggle refinement on already selected facet press', async () => { + it('should NOT toggle refinement on already selected facet press', async () => { const user = userEvent.setup() - render(<SearchFacetsChips />) - - await user.press(screen.getByLabelText('search.typesense.facets.space')) - - expect(refine).not.toHaveBeenCalled() - expect(clearRefinement).toHaveBeenCalled() - }) - - it('should clear refinment but not refine search when facet "all" is pressed', async () => { - const user = userEvent.setup() - - render(<SearchFacetsChips />) - - await user.press(screen.getByLabelText('search.typesense.facets.all')) - - expect(refine).not.toHaveBeenCalled() - expect(clearRefinement).toHaveBeenCalled() - }) + render(<SearchFacetsChips initialFacet="space" />) - it('should call facet change callback with undefined when facet "all" is pressed', async () => { - const onFacetChange = jest.fn() - const user = userEvent.setup() + // Space is initally selected (in the mocked facet state) + const chip = screen.getByLabelText('search.typesense.facets.space') + expect(chip).toHaveAccessibilityState({ checked: true }) - render(<SearchFacetsChips onFacetChange={onFacetChange} />) + mockRefine.mockReset() - await user.press(screen.getByLabelText('search.typesense.facets.all')) + await user.press(screen.getByLabelText('search.typesense.facets.space')) - expect(onFacetChange).toHaveBeenCalledWith(undefined) + expect(mockRefine).not.toHaveBeenCalled() + expect(clearRefinement).not.toHaveBeenCalled() }) it('applies initial selection', async () => { @@ -121,4 +105,39 @@ describe('SearchFacetsChips', () => { const chip = screen.getByLabelText('search.typesense.facets.space') expect(chip).toHaveAccessibilityState({ checked: true }) }) + + it('should select volunteering if no initial facet selection is given', async () => { + render(<SearchFacetsChips />) + + expect(mockRefine).toHaveBeenCalledWith('volunteering') + }) + + it('should order facets in a stable way', async () => { + const validateOrder = (expectedOrder: string[]) => { + const labels = screen + .getAllByLabelText(/^search\.typesense\.facets\.[^.]+$/) + .map((facet) => facet.props.accessibilityLabel) + expect(labels).toEqual(expectedOrder) + } + + // order should always be [volunteering, spaces, profiles], no matter which order is returned by Typesense + const { rerender } = render(<SearchFacetsChips />) + validateOrder([ + 'search.typesense.facets.volunteering', + 'search.typesense.facets.space', + 'search.typesense.facets.profile', + ]) + + mockRefinementList({ items: facets.reverse() }) + rerender(<SearchFacetsChips />) + validateOrder([ + 'search.typesense.facets.volunteering', + 'search.typesense.facets.space', + 'search.typesense.facets.profile', + ]) + + mockRefinementList({ items: [facets[2], facets[0]] }) + rerender(<SearchFacetsChips />) + validateOrder(['search.typesense.facets.volunteering', 'search.typesense.facets.space']) + }) }) diff --git a/holi-bricks/components/selectors/Selector.test.tsx b/holi-bricks/components/selectors/Selector.test.tsx index 4aced82ec46f44d877df094a842cf242f3837e81..b81d5d46a4f35bf977ba4fa7f651d26b97695f62 100644 --- a/holi-bricks/components/selectors/Selector.test.tsx +++ b/holi-bricks/components/selectors/Selector.test.tsx @@ -78,6 +78,21 @@ describe('Selector', () => { expect(mockFn).toHaveBeenCalledWith(['1']) }) + it('does not toggle selection in radio mode', () => { + const mockFn = jest.fn() + render( + <Selector title={'title'} onChange={mockFn} inputType="radio"> + <SelectableCard title={'card1'} id={'1'} /> + <SelectableCard title={'card2'} id={'2'} /> + </Selector> + ) + fireEvent.press(screen.getByText('card1')) + expect(mockFn).toHaveBeenCalledWith(['1']) + + fireEvent.press(screen.getByText('card1')) + expect(mockFn).toHaveBeenCalledWith(['1']) + }) + it('calls childrens onPress correctly when clicked', () => { const mockFn = jest.fn() const mockFn1 = jest.fn() diff --git a/holi-bricks/components/selectors/Selector.tsx b/holi-bricks/components/selectors/Selector.tsx index 930d11c3ab2f8def72c0eb4f14862790bcbd064e..e94b749ee47517d21202058492904ec59baf19e3 100644 --- a/holi-bricks/components/selectors/Selector.tsx +++ b/holi-bricks/components/selectors/Selector.tsx @@ -50,7 +50,7 @@ export const Selector = ({ (id: string) => { onSelect?.(id) const newSelectedOptions = - inputType === 'radio' ? { [id]: !selectedOptions[id] } : { ...selectedOptions, [id]: !selectedOptions[id] } + inputType === 'radio' ? { [id]: true } : { ...selectedOptions, [id]: !selectedOptions[id] } const newSelectedOptionsAsStringArray = Object.keys(newSelectedOptions).filter((key) => newSelectedOptions[key]) onChange?.(newSelectedOptionsAsStringArray) setSelectedOptions(newSelectedOptions)