diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index b2fe866a0e..ed13c66cbb 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -530,4 +530,108 @@ describe('', () => { }); }); }); + + 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(); + + // 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(); + + 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(); + }); }); diff --git a/src/search-manager/FilterBy.scss b/src/search-manager/FilterBy.scss index 3caccac691..7579958f6f 100644 --- a/src/search-manager/FilterBy.scss +++ b/src/search-manager/FilterBy.scss @@ -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; + } +} diff --git a/src/search-manager/FilterByBlockType.tsx b/src/search-manager/FilterByBlockType.tsx index f592b713ae..993e22200b 100644 --- a/src/search-manager/FilterByBlockType.tsx +++ b/src/search-manager/FilterByBlockType.tsx @@ -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(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 ( +
+ +
+
+ {' '} + {count} +
+ { Object.keys(problemTypes).length !== 0 && ( + + )} +
+
+ +
+ + + + { Object.entries(problemTypes).map(([problemType, problemTypeCount]) => ( + +
+ {' '} + {problemTypeCount} +
+
+ ))} + { + // 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 */ + + ) : null + } +
+
+
+
+
+
+ ); +}; + +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 ( + + ); + } + + return ( + +
+ {' '} + {count} +
+
+ ); +}; + /** * 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> = () => { 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> = () => { 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: }), + ); return ( ({ label: }))} + appliedFilters={appliedFilters} label={} - clearFilter={() => setBlockTypesFilter([])} + clearFilter={clearFilters} icon={FilterList} > { Object.entries(sortedBlockTypes).map(([blockType, count]) => ( - + )) } { diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index d75a6cbdba..76361c0924 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -19,9 +19,12 @@ export interface SearchContextData { setSearchKeywords: React.Dispatch>; blockTypesFilter: string[]; setBlockTypesFilter: React.Dispatch>; + problemTypesFilter: string[]; + setProblemTypesFilter: React.Dispatch>; tagsFilter: string[]; setTagsFilter: React.Dispatch>; blockTypes: Record; + problemTypes: Record; 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([]); + const [problemTypesFilter, setProblemTypesFilter] = React.useState([]); const [tagsFilter, setTagsFilter] = React.useState([]); 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, diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 42d04981ef..d220787929 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -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, + problemTypes: Record, }> { 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, }; } diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index 2b0b2e3227..f5af9a159a 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -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, diff --git a/src/search-manager/messages.ts b/src/search-manager/messages.ts index 1fa3e229ea..2c9eadcca8 100644 --- a/src/search-manager/messages.ts +++ b/src/search-manager/messages.ts @@ -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', diff --git a/src/search-modal/SearchUI.test.tsx b/src/search-modal/SearchUI.test.tsx index 003a876b16..1574b68917 100644 --- a/src/search-modal/SearchUI.test.tsx +++ b/src/search-modal/SearchUI.test.tsx @@ -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('', () => { 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('', () => { 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('', () => { }); 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('', () => { 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('', () => { 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('', () => { 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"', diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json index 9e60dfaa93..6800f83065 100644 --- a/src/search-modal/__mocks__/search-result.json +++ b/src/search-modal/__mocks__/search-result.json @@ -355,6 +355,13 @@ "problem": 16, "vertical": 2, "video": 1 + }, + "content.problem_types": { + "choiceresponse": 2, + "multiplechoiceresponse": 6, + "numericalresponse": 3, + "optionresponse": 4, + "stringresponse": 1 } }, "facetStats": {}