Skip to content

Commit

Permalink
feat: Allow filtering by multiple tags [FC-0040] (#945)
Browse files Browse the repository at this point in the history
As of #918 , the content search only allows filtering the results by one tag at a time, which is a limitation of Instantsearch.

So with this change, usage of Instantsearch + instant-meilisearch has been replaced with direct usage of Meilisearch. Not only does this simplify the code and make our MFE bundle size smaller, but it allows us much more control over how the tags filtering works, so that we can implement searching by multiple tags.

Trying to modify Instantsearch to do that was too difficult, given the complexity of its codebase.

Related ticket: openedx/modular-learning#201
  • Loading branch information
bradenmacdonald authored Apr 24, 2024
1 parent 3410449 commit c32462e
Show file tree
Hide file tree
Showing 27 changed files with 1,272 additions and 734 deletions.
341 changes: 1 addition & 340 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,15 @@
"email-validator": "2.0.4",
"file-saver": "^2.0.5",
"formik": "2.2.6",
"instantsearch.css": "^8.1.0",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"meilisearch": "^0.38.0",
"moment": "2.29.4",
"prop-types": "15.7.2",
"react": "17.0.2",
"react-datepicker": "^4.13.0",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-instantsearch": "^7.7.1",
"react-redux": "7.2.9",
"react-responsive": "9.0.2",
"react-router": "6.16.0",
Expand Down
8 changes: 4 additions & 4 deletions src/search-modal/ClearFiltersButton.jsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { useClearRefinements } from 'react-instantsearch';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import messages from './messages';
import { useSearchContext } from './manager/SearchManager';

/**
* A button that appears when at least one filter is active, and will clear the filters when clicked.
* @type {React.FC<Record<never, never>>}
*/
const ClearFiltersButton = () => {
const { refine, canRefine } = useClearRefinements();
if (canRefine) {
const { canClearFilters, clearFilters } = useSearchContext();
if (canClearFilters) {
return (
<Button variant="link" size="sm" onClick={refine}>
<Button variant="link" size="sm" onClick={clearFilters}>
<FormattedMessage {...messages.clearFilters} />
</Button>
);
Expand Down
23 changes: 17 additions & 6 deletions src/search-modal/EmptyStates.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Stack } from '@openedx/paragon';
import { useStats, useClearRefinements } from 'react-instantsearch';
import { Alert, Stack } from '@openedx/paragon';

import { useSearchContext } from './manager/SearchManager';
import EmptySearchImage from './images/empty-search.svg';
import NoResultImage from './images/no-results.svg';
import messages from './messages';
Expand All @@ -24,10 +24,21 @@ const InfoMessage = ({ title, subtitle, image }) => (
* @type {React.FC<{children: React.ReactElement}>}
*/
const EmptyStates = ({ children }) => {
const { nbHits, query } = useStats();
const { canRefine: hasFiltersApplied } = useClearRefinements();
const hasQuery = !!query;
const {
canClearFilters: hasFiltersApplied,
totalHits,
searchKeywords,
hasError,
} = useSearchContext();
const hasQuery = !!searchKeywords;

if (hasError) {
return (
<Alert variant="danger">
<FormattedMessage {...messages.searchError} />
</Alert>
);
}
if (!hasQuery && !hasFiltersApplied) {
// We haven't started the search yet. Display the "start your search" empty state
return (
Expand All @@ -38,7 +49,7 @@ const EmptyStates = ({ children }) => {
/>
);
}
if (nbHits === 0) {
if (totalHits === 0) {
return (
<InfoMessage
title={messages.noResultsTitle}
Expand Down
57 changes: 22 additions & 35 deletions src/search-modal/FilterByBlockType.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Button,
Badge,
Form,
Menu,
MenuItem,
} from '@openedx/paragon';
import {
useCurrentRefinements,
useRefinementList,
} from 'react-instantsearch';
import SearchFilterWidget from './SearchFilterWidget';
import messages from './messages';
import BlockTypeLabel from './BlockTypeLabel';
import { useSearchContext } from './manager/SearchManager';

/**
* A button with a dropdown that allows filtering the current search by component type (XBlock type)
Expand All @@ -25,64 +21,55 @@ import BlockTypeLabel from './BlockTypeLabel';
*/
const FilterByBlockType = () => {
const {
items,
refine,
canToggleShowMore,
isShowingMore,
toggleShowMore,
} = useRefinementList({ attribute: 'block_type', sortBy: ['count:desc', 'name'] });

// Get the list of applied 'items' (selected block types to filter) in the original order that the user clicked them.
// The first choice will be shown on the button, and we don't want it to change as the user selects more options.
// (But for the dropdown menu, we always want them sorted by 'count:desc' and 'name'; not in order of selection.)
const refinementsData = useCurrentRefinements({ includedAttributes: ['block_type'] });
const appliedItems = refinementsData.items[0]?.refinements ?? [];
// If we didn't need to preserve the order the user clicked on, the above two lines could be simplified to:
// const appliedItems = items.filter(item => item.isRefined);
blockTypes,
blockTypesFilter,
setBlockTypesFilter,
} = useSearchContext();
// TODO: sort blockTypes first by count, then by name

const handleCheckboxChange = React.useCallback((e) => {
refine(e.target.value);
}, [refine]);
setBlockTypesFilter(currentFilters => {
if (currentFilters.includes(e.target.value)) {
return currentFilters.filter(x => x !== e.target.value);
}
return [...currentFilters, e.target.value];
});
}, [setBlockTypesFilter]);

return (
<SearchFilterWidget
appliedFilters={appliedItems.map(item => ({ label: <BlockTypeLabel type={String(item.value)} /> }))}
appliedFilters={blockTypesFilter.map(blockType => ({ label: <BlockTypeLabel type={blockType} /> }))}
label={<FormattedMessage {...messages.blockTypeFilter} />}
>
<Form.Group>
<Form.CheckboxSet
name="block-type-filter"
defaultValue={appliedItems.map(item => item.value)}
defaultValue={blockTypesFilter}
>
<Menu style={{ boxShadow: 'none' }}>
{
items.map((item) => (
Object.entries(blockTypes).map(([blockType, count]) => (
<MenuItem
key={item.value}
key={blockType}
as={Form.Checkbox}
value={item.value}
checked={item.isRefined}
value={blockType}
checked={blockTypesFilter.includes(blockType)}
onChange={handleCheckboxChange}
>
<BlockTypeLabel type={item.value} />{' '}
<Badge variant="light" pill>{item.count}</Badge>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</MenuItem>
))
}
{
// Show a message if there are no options at all to avoid the impression that the dropdown isn't working
items.length === 0 ? (
blockTypes.length === 0 ? (
<MenuItem disabled><FormattedMessage {...messages['blockTypeFilter.empty']} /></MenuItem>
) : null
}
</Menu>
</Form.CheckboxSet>
</Form.Group>
{
canToggleShowMore && !isShowingMore
? <Button onClick={toggleShowMore}><FormattedMessage {...messages.showMore} /></Button>
: null
}
</SearchFilterWidget>
);
};
Expand Down
Loading

0 comments on commit c32462e

Please sign in to comment.