feat: Capa problem types submenu [FC-0059] (#1207)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
))
|
||||
}
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -355,6 +355,13 @@
|
||||
"problem": 16,
|
||||
"vertical": 2,
|
||||
"video": 1
|
||||
},
|
||||
"content.problem_types": {
|
||||
"choiceresponse": 2,
|
||||
"multiplechoiceresponse": 6,
|
||||
"numericalresponse": 3,
|
||||
"optionresponse": 4,
|
||||
"stringresponse": 1
|
||||
}
|
||||
},
|
||||
"facetStats": {}
|
||||
|
||||
Reference in New Issue
Block a user