feat: Capa problem types submenu [FC-0059] (#1207)

This commit is contained in:
Chris Chávez
2024-08-28 08:16:02 -05:00
committed by GitHub
parent d99a09efba
commit 64ffaddf3c
9 changed files with 418 additions and 35 deletions

View File

@@ -530,4 +530,108 @@ describe('<LibraryAuthoringPage />', () => {
});
});
});
it('filter by capa problem type', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const problemTypes = {
'Multiple Choice': 'choiceresponse',
Checkboxes: 'multiplechoiceresponse',
'Numerical Input': 'numericalresponse',
Dropdown: 'optionresponse',
'Text Input': 'stringresponse',
};
render(<RootWrapper />);
// Ensure the search endpoint is called
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
const filterButton = screen.getByRole('button', { name: /type/i });
fireEvent.click(filterButton);
const openProblemItem = screen.getByTestId('open-problem-item-button');
fireEvent.click(openProblemItem);
const validateSubmenu = async (submenuText : string) => {
const submenu = screen.getByText(submenuText);
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
});
fireEvent.click(submenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
});
};
// Validate per submenu
// eslint-disable-next-line no-restricted-syntax
for (const key of Object.keys(problemTypes)) {
// eslint-disable-next-line no-await-in-loop
await validateSubmenu(key);
}
// Validate click on Problem type
const problemMenu = screen.getByText('Problem');
expect(problemMenu).toBeInTheDocument();
fireEvent.click(problemMenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('block_type = problem'),
method: 'POST',
headers: expect.anything(),
});
});
fireEvent.click(problemMenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining('block_type = problem'),
method: 'POST',
headers: expect.anything(),
});
});
// Validate clear filters
const submenu = screen.getByText('Checkboxes');
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);
const clearFitlersButton = screen.getByRole('button', { name: /clear filters/i });
fireEvent.click(clearFitlersButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining(`content.problem_types = ${problemTypes.Checkboxes}`),
method: 'POST',
headers: expect.anything(),
});
});
});
it('empty type filter', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
render(<RootWrapper />);
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
const filterButton = screen.getByRole('button', { name: /type/i });
fireEvent.click(filterButton);
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});
});

View File

@@ -9,3 +9,19 @@
.clear-filter-button:hover {
color: $info-900 !important;
}
.problem-menu-item {
.pgn__menu-item-text {
width: 100%;
}
.pgn__form-checkbox > div:first-of-type {
width: 100%;
}
.problem-sub-menu-item {
position: absolute;
left: 3.8rem;
top: -3rem;
}
}

View File

@@ -1,17 +1,212 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import React, { useCallback, useEffect } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Badge,
Form,
Icon,
IconButton,
Menu,
MenuItem,
ModalPopup,
useToggle,
} from '@openedx/paragon';
import { FilterList } from '@openedx/paragon/icons';
import { KeyboardArrowRight, FilterList } from '@openedx/paragon/icons';
import SearchFilterWidget from './SearchFilterWidget';
import messages from './messages';
import BlockTypeLabel from './BlockTypeLabel';
import { useSearchContext } from './SearchManager';
interface ProblemFilterItemProps {
count: number,
handleCheckboxChange: Function,
}
interface FilterItemProps {
blockType: string,
count: number,
}
const ProblemFilterItem = ({ count, handleCheckboxChange } : ProblemFilterItemProps) => {
const blockType = 'problem';
const {
setBlockTypesFilter,
problemTypes,
problemTypesFilter,
blockTypesFilter,
setProblemTypesFilter,
} = useSearchContext();
const intl = useIntl();
const problemTypesLength = Object.values(problemTypes).length;
const [isProblemItemOpen, openProblemItem, closeProblemItem] = useToggle(false);
const [isProblemIndeterminate, setIsProblemIndeterminate] = React.useState(false);
const [problemItemTarget, setProblemItemTarget] = React.useState<HTMLButtonElement | null>(null);
useEffect(() => {
/* istanbul ignore next */
if (problemTypesFilter.length !== 0
&& !blockTypesFilter.includes(blockType)) {
setIsProblemIndeterminate(true);
}
}, []);
const handleCheckBoxChangeOnProblem = React.useCallback((e) => {
handleCheckboxChange(e);
if (e.target.checked) {
setProblemTypesFilter(Object.keys(problemTypes));
} else {
setProblemTypesFilter([]);
}
}, [handleCheckboxChange, setProblemTypesFilter]);
const handleProblemCheckboxChange = React.useCallback((e) => {
setProblemTypesFilter(currentFiltersProblem => {
let result;
if (currentFiltersProblem.includes(e.target.value)) {
result = currentFiltersProblem.filter(x => x !== e.target.value);
} else {
result = [...currentFiltersProblem, e.target.value];
}
if (e.target.checked) {
/* istanbul ignore next */
if (result.length === problemTypesLength) {
// Add 'problem' to type filter if all problem types are selected.
setIsProblemIndeterminate(false);
setBlockTypesFilter(currentFilters => [...currentFilters, 'problem']);
} else {
setIsProblemIndeterminate(true);
}
} /* istanbul ignore next */ else {
// Delete 'problem' filter if a problem is deselected.
setBlockTypesFilter(currentFilters => {
/* istanbul ignore next */
if (currentFilters.includes('problem')) {
return currentFilters.filter(x => x !== 'problem');
}
return [...currentFilters];
});
setIsProblemIndeterminate(result.length !== 0);
}
return result;
});
}, [
setProblemTypesFilter,
problemTypesFilter,
setBlockTypesFilter,
problemTypesLength,
]);
return (
<div className="problem-menu-item">
<MenuItem
key={blockType}
as={Form.Checkbox}
value={blockType}
onChange={handleCheckBoxChangeOnProblem}
isIndeterminate={isProblemIndeterminate}
>
<div className="d-flex justify-content-between align-items-center">
<div>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</div>
{ Object.keys(problemTypes).length !== 0 && (
<IconButton
ref={setProblemItemTarget}
variant="dark"
iconAs={Icon}
src={KeyboardArrowRight}
onClick={openProblemItem}
data-testid="open-problem-item-button"
alt={intl.formatMessage(messages.openProblemSubmenuAlt)}
/>
)}
</div>
</MenuItem>
<ModalPopup
positionRef={problemItemTarget}
isOpen={isProblemItemOpen}
onClose={closeProblemItem}
>
<div
className="bg-white rounded shadow problem-sub-menu-item"
>
<Form.Group className="mb-0">
<Form.CheckboxSet
name="block-type-filter"
value={problemTypesFilter}
>
<Menu>
{ Object.entries(problemTypes).map(([problemType, problemTypeCount]) => (
<MenuItem
key={problemType}
as={Form.Checkbox}
value={problemType}
onChange={handleProblemCheckboxChange}
>
<div style={{ textAlign: 'start' }}>
<BlockTypeLabel type={problemType} />{' '}
<Badge variant="light" pill>{problemTypeCount}</Badge>
</div>
</MenuItem>
))}
{
// Show a message if there are no options at all to avoid the
// impression that the dropdown isn't working
Object.keys(problemTypes).length === 0 ? (
/* istanbul ignore next */
<MenuItem disabled><FormattedMessage {...messages['blockTypeFilter.empty']} /></MenuItem>
) : null
}
</Menu>
</Form.CheckboxSet>
</Form.Group>
</div>
</ModalPopup>
</div>
);
};
const FilterItem = ({ blockType, count } : FilterItemProps) => {
const {
setBlockTypesFilter,
} = useSearchContext();
const handleCheckboxChange = React.useCallback((e) => {
setBlockTypesFilter(currentFilters => {
if (currentFilters.includes(e.target.value)) {
return currentFilters.filter(x => x !== e.target.value);
}
return [...currentFilters, e.target.value];
});
}, [setBlockTypesFilter]);
if (blockType === 'problem') {
// Build Capa Problem types filter submenu
return (
<ProblemFilterItem
count={count}
handleCheckboxChange={handleCheckboxChange}
/>
);
}
return (
<MenuItem
key={blockType}
as={Form.Checkbox}
value={blockType}
onChange={handleCheckboxChange}
>
<div>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</div>
</MenuItem>
);
};
/**
* A button with a dropdown that allows filtering the current search by component type (XBlock type)
* e.g. Limit results to "Text" (html) and "Problem" (problem) components.
@@ -21,9 +216,16 @@ const FilterByBlockType: React.FC<Record<never, never>> = () => {
const {
blockTypes,
blockTypesFilter,
problemTypesFilter,
setBlockTypesFilter,
setProblemTypesFilter,
} = useSearchContext();
const clearFilters = useCallback(/* istanbul ignore next */ () => {
setBlockTypesFilter([]);
setProblemTypesFilter([]);
}, []);
// Sort blocktypes in order of hierarchy followed by alphabetically for components
const sortedBlockTypeKeys = Object.keys(blockTypes).sort((a, b) => {
const order = {
@@ -57,41 +259,26 @@ const FilterByBlockType: React.FC<Record<never, never>> = () => {
sortedBlockTypes[key] = blockTypes[key];
});
const handleCheckboxChange = React.useCallback((e) => {
setBlockTypesFilter(currentFilters => {
if (currentFilters.includes(e.target.value)) {
return currentFilters.filter(x => x !== e.target.value);
}
return [...currentFilters, e.target.value];
});
}, [setBlockTypesFilter]);
const appliedFilters = [...blockTypesFilter, ...problemTypesFilter].map(
blockType => ({ label: <BlockTypeLabel type={blockType} /> }),
);
return (
<SearchFilterWidget
appliedFilters={blockTypesFilter.map(blockType => ({ label: <BlockTypeLabel type={blockType} /> }))}
appliedFilters={appliedFilters}
label={<FormattedMessage {...messages.blockTypeFilter} />}
clearFilter={() => setBlockTypesFilter([])}
clearFilter={clearFilters}
icon={FilterList}
>
<Form.Group className="mb-0">
<Form.CheckboxSet
name="block-type-filter"
defaultValue={blockTypesFilter}
value={blockTypesFilter}
>
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
{
Object.entries(sortedBlockTypes).map(([blockType, count]) => (
<label key={blockType} className="d-inline">
<MenuItem
as={Form.Checkbox}
value={blockType}
checked={blockTypesFilter.includes(blockType)}
onChange={handleCheckboxChange}
>
<BlockTypeLabel type={blockType} />{' '}
<Badge variant="light" pill>{count}</Badge>
</MenuItem>
</label>
<FilterItem blockType={blockType} count={count} />
))
}
{

View File

@@ -19,9 +19,12 @@ export interface SearchContextData {
setSearchKeywords: React.Dispatch<React.SetStateAction<string>>;
blockTypesFilter: string[];
setBlockTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
problemTypesFilter: string[];
setProblemTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
tagsFilter: string[];
setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>;
blockTypes: Record<string, number>;
problemTypes: Record<string, number>;
extraFilter?: Filter;
canClearFilters: boolean;
clearFilters: () => void;
@@ -88,6 +91,7 @@ export const SearchContextProvider: React.FC<{
}> = ({ overrideSearchSortOrder, ...props }) => {
const [searchKeywords, setSearchKeywords] = React.useState('');
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
const extraFilter: string[] = forceArray(props.extraFilter);
@@ -112,12 +116,14 @@ export const SearchContextProvider: React.FC<{
const canClearFilters = (
blockTypesFilter.length > 0
|| problemTypesFilter.length > 0
|| tagsFilter.length > 0
);
const isFiltered = canClearFilters || (searchKeywords !== '');
const clearFilters = React.useCallback(() => {
setBlockTypesFilter([]);
setTagsFilter([]);
setProblemTypesFilter([]);
}, []);
// Initialize a connection to Meilisearch:
@@ -137,6 +143,7 @@ export const SearchContextProvider: React.FC<{
extraFilter,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
});
@@ -149,6 +156,8 @@ export const SearchContextProvider: React.FC<{
setSearchKeywords,
blockTypesFilter,
setBlockTypesFilter,
problemTypesFilter,
setProblemTypesFilter,
tagsFilter,
setTagsFilter,
extraFilter,

View File

@@ -140,6 +140,7 @@ interface FetchSearchParams {
indexName: string,
searchKeywords: string,
blockTypesFilter?: string[],
problemTypesFilter?: string[],
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
tagsFilter?: string[],
extraFilter?: Filter,
@@ -153,6 +154,7 @@ export async function fetchSearchResults({
indexName,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
extraFilter,
sort,
@@ -162,6 +164,7 @@ export async function fetchSearchResults({
nextOffset: number | undefined,
totalHits: number,
blockTypes: Record<string, number>,
problemTypes: Record<string, number>,
}> {
const queries: MultiSearchQuery[] = [];
@@ -170,10 +173,18 @@ export async function fetchSearchResults({
const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : [];
const problemTypesFilterFormatted = problemTypesFilter?.length ? [problemTypesFilter.map(pt => `content.problem_types = ${pt}`)] : [];
const tagsFilterFormatted = formatTagsFilter(tagsFilter);
const limit = 20; // How many results to retrieve per page.
// To filter normal block types and problem types as 'OR' query
const typeFilters = [[
...blockTypesFilterFormatted,
...problemTypesFilterFormatted,
].flat()];
// First query is always to get the hits, with all the filters applied.
queries.push({
indexUid: indexName,
@@ -181,8 +192,8 @@ export async function fetchSearchResults({
filter: [
// top-level entries in the array are AND conditions and must all match
// Inner arrays are OR conditions, where only one needs to match.
...typeFilters,
...extraFilterFormatted,
...blockTypesFilterFormatted,
...tagsFilterFormatted,
],
attributesToHighlight: ['display_name', 'content'],
@@ -199,7 +210,7 @@ export async function fetchSearchResults({
queries.push({
indexUid: indexName,
q: searchKeywords,
facets: ['block_type'],
facets: ['block_type', 'content.problem_types'],
filter: [
...extraFilterFormatted,
// We exclude the block type filter here so we get all the other available options for it.
@@ -213,6 +224,7 @@ export async function fetchSearchResults({
hits: results[0].hits.map(formatSearchHit),
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length,
blockTypes: results[1].facetDistribution?.block_type ?? {},
problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {},
nextOffset: results[0].hits.length === limit ? offset + limit : undefined,
};
}

View File

@@ -37,6 +37,7 @@ export const useContentSearchResults = ({
extraFilter,
searchKeywords,
blockTypesFilter = [],
problemTypesFilter = [],
tagsFilter = [],
sort = [],
}: {
@@ -50,6 +51,8 @@ export const useContentSearchResults = ({
searchKeywords: string;
/** Only search for these block types (e.g. `["html", "problem"]`) */
blockTypesFilter?: string[];
/** Only search for these problem types (e.g. `["choiceresponse", "multiplechoiceresponse"]`) */
problemTypesFilter?: string[];
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
tagsFilter?: string[];
/** Sort search results using these options */
@@ -66,6 +69,7 @@ export const useContentSearchResults = ({
extraFilter,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
],
@@ -79,6 +83,7 @@ export const useContentSearchResults = ({
indexName,
searchKeywords,
blockTypesFilter,
problemTypesFilter,
tagsFilter,
sort,
// For infinite pagination of results, we can retrieve additional pages if requested.
@@ -102,6 +107,7 @@ export const useContentSearchResults = ({
hits,
// The distribution of block type filter options
blockTypes: pages?.[0]?.blockTypes ?? {},
problemTypes: pages?.[0]?.problemTypes ?? {},
status: query.status,
isFetching: query.isFetching,
isError: query.isError,

View File

@@ -105,6 +105,36 @@ const messages = defineMessages({
defaultMessage: 'Video',
description: 'Name of the "Video" component type in Studio',
},
'blockType.choiceresponse': {
id: 'course-authoring.course-search.blockType.choiceresponse',
defaultMessage: 'Multiple Choice',
description: 'Name of the "choiceresponse" component type in Studio',
},
'blockType.multiplechoiceresponse': {
id: 'course-authoring.course-search.blockType.multiplechoiceresponse',
defaultMessage: 'Checkboxes',
description: 'Name of the "multiplechoiceresponse" component type in Studio',
},
'blockType.numericalresponse': {
id: 'course-authoring.course-search.blockType.numericalresponse',
defaultMessage: 'Numerical Input',
description: 'Name of the "numericalresponse" component type in Studio',
},
'blockType.optionresponse': {
id: 'course-authoring.course-search.blockType.optionresponse',
defaultMessage: 'Dropdown',
description: 'Name of the "optionresponse" component type in Studio',
},
'blockType.stringresponse': {
id: 'course-authoring.course-search.blockType.stringresponse',
defaultMessage: 'Text Input',
description: 'Name of the "stringresponse" component type in Studio',
},
'blockType.formularesponse': {
id: 'course-authoring.course-search.blockType.formularesponse',
defaultMessage: 'Math Expression',
description: 'Name of the "formularesponse" component type in Studio',
},
blockTagsFilter: {
id: 'course-authoring.search-manager.blockTagsFilter',
defaultMessage: 'Tags',
@@ -170,6 +200,11 @@ const messages = defineMessages({
defaultMessage: 'Recently Modified',
description: 'Label for the content search sort drop-down which sorts by modified date, descending',
},
openProblemSubmenuAlt: {
id: 'course-authoring.filter.problem-submenu.icon-button.alt',
defaultMessage: 'Open problem types filters',
description: 'Alt of the icon button to open problem types filters',
},
searchSortMostRelevant: {
id: 'course-authoring.course-search.searchSort.mostRelevant',
defaultMessage: 'Most Relevant',

View File

@@ -10,7 +10,6 @@ import {
render,
waitFor,
within,
getByLabelText as getByLabelTextIn,
type RenderResult,
} from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';
@@ -175,8 +174,8 @@ describe('<SearchUI />', () => {
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return requestedFilter?.[0] === 'type = "course_block"'
&& requestedFilter?.[1] === 'context_key = "course-v1:org+test+123"';
return requestedFilter?.[1] === 'type = "course_block"'
&& requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"';
});
// Now we should see the results:
expect(queryByText('Enter a keyword')).toBeNull();
@@ -400,7 +399,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
// the filter is: ['type = "course_block"', 'context_key = "course-v1:org+test+123"']
return (requestedFilter?.length === 2);
return (requestedFilter?.length === 3);
});
// Now we should see the results:
expect(getByText('6 results found')).toBeInTheDocument();
@@ -408,13 +407,12 @@ describe('<SearchUI />', () => {
});
it('can filter results by component/XBlock type', async () => {
const { getByRole } = rendered;
const { getByRole, getByText } = rendered;
// Now open the filters menu:
fireEvent.click(getByRole('button', { name: 'Type' }), {});
// The dropdown menu has role="group"
await waitFor(() => { expect(getByRole('group')).toBeInTheDocument(); });
const popupMenu = getByRole('group');
const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i);
const problemFilterCheckbox = getByText(/Problem/i);
fireEvent.click(problemFilterCheckbox, {});
await waitFor(() => {
expect(rendered.getByRole('button', { name: /type: problem/i, hidden: true })).toBeInTheDocument();
@@ -427,9 +425,16 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[
'block_type = problem',
'content.problem_types = choiceresponse',
'content.problem_types = multiplechoiceresponse',
'content.problem_types = numericalresponse',
'content.problem_types = optionresponse',
'content.problem_types = stringresponse',
],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
['block_type = problem'], // <-- the newly added filter, sent with the request
]);
});
});
@@ -453,6 +458,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
'tags.taxonomy = "ESDC Skills and Competencies"', // <-- the newly added filter, sent with the request
@@ -487,6 +493,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
'tags.level0 = "ESDC Skills and Competencies > Abilities"',

View File

@@ -355,6 +355,13 @@
"problem": 16,
"vertical": 2,
"video": 1
},
"content.problem_types": {
"choiceresponse": 2,
"multiplechoiceresponse": 6,
"numericalresponse": 3,
"optionresponse": 4,
"stringresponse": 1
}
},
"facetStats": {}