chore: convert all 'search-modal' code to TypeScript (#1129)

* chore: convert all 'search-modal' code to TypeScript

* fix: lint should check .ts[x] files too

* fix: remove unused dependency meilisearch-instantsearch
This commit is contained in:
Braden MacDonald
2024-06-27 09:54:01 -07:00
committed by GitHub
parent 22ea32cf01
commit a4859d2686
22 changed files with 261 additions and 297 deletions

9
package-lock.json generated
View File

@@ -26,7 +26,6 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@meilisearch/instant-meilisearch": "^0.17.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
@@ -4073,14 +4072,6 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@meilisearch/instant-meilisearch": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@meilisearch/instant-meilisearch/-/instant-meilisearch-0.17.0.tgz",
"integrity": "sha512-6SDDivDWsmYjX33m2fAUCcBvatjutBbvqV8Eg+CEllz0l6piAiDK/WlukVpYrSmhYN2YGQsJSm62WbMGciPhUg==",
"dependencies": {
"meilisearch": "^0.38.0"
}
},
"node_modules/@newrelic/publish-sourcemap": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@newrelic/publish-sourcemap/-/publish-sourcemap-5.1.0.tgz",

View File

@@ -13,7 +13,7 @@
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
@@ -54,7 +54,6 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@meilisearch/instant-meilisearch": "^0.17.0",
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",

View File

@@ -5,24 +5,24 @@ import type {} from 'react-select/base';
// and add our custom property 'myCustomProp' to it.
export interface TagTreeEntry {
explicit: boolean;
children: Record<string, TagTreeEntry>;
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
explicit: boolean;
children: Record<string, TagTreeEntry>;
canChangeObjecttag: boolean;
canDeleteObjecttag: boolean;
}
export interface TaxonomySelectProps {
taxonomyId: number;
searchTerm: string;
appliedContentTagsTree: Record<string, TagTreeEntry>;
stagedContentTagsTree: Record<string, TagTreeEntry>;
checkedTags: string[];
selectCancelRef: Ref,
selectAddRef: Ref,
selectInlineAddRef: Ref,
handleCommitStagedTags: () => void;
handleCancelStagedTags: () => void;
handleSelectableBoxChange: React.ChangeEventHandler;
taxonomyId: number;
searchTerm: string;
appliedContentTagsTree: Record<string, TagTreeEntry>;
stagedContentTagsTree: Record<string, TagTreeEntry>;
checkedTags: string[];
selectCancelRef: Ref,
selectAddRef: Ref,
selectInlineAddRef: Ref,
handleCommitStagedTags: () => void;
handleCancelStagedTags: () => void;
handleSelectableBoxChange: React.ChangeEventHandler;
}
// Unfortunately the only way to specify the custom props we pass into React Select
@@ -32,11 +32,8 @@ export interface TaxonomySelectProps {
// we should change to using a 'react context' to share this data within <ContentTagsCollapsible>,
// rather than using the custom <Select> Props (selectProps).
declare module 'react-select/base' {
export interface Props<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> extends TaxonomySelectProps {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> extends TaxonomySelectProps {
}
}

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
@@ -7,9 +5,8 @@ import messages from './messages';
/**
* Displays a friendly, localized text name for the given XBlock/component type
* e.g. `vertical` becomes `"Unit"`
* @type {React.FC<{type: string}>}
*/
const BlockTypeLabel = ({ type }) => {
const BlockTypeLabel: React.FC<{ type: string }> = ({ type }) => {
// TODO: Load the localized list of Component names from Studio REST API?
const msg = messages[`blockType.${type}`];

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
@@ -8,9 +6,8 @@ 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 ClearFiltersButton: React.FC<Record<never, never>> = () => {
const { canClearFilters, clearFilters } = useSearchContext();
if (canClearFilters) {
return (

View File

@@ -1,7 +1,6 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import type { MessageDescriptor } from 'react-intl';
import { Alert, Stack } from '@openedx/paragon';
import { useSearchContext } from './manager/SearchManager';
@@ -9,11 +8,17 @@ import EmptySearchImage from './images/empty-search.svg';
import NoResultImage from './images/no-results.svg';
import messages from './messages';
const InfoMessage = ({ title, subtitle, image }) => (
interface InfoMessageProps {
title: MessageDescriptor;
subtitle: MessageDescriptor;
image: string;
}
const InfoMessage = (props: InfoMessageProps) => (
<Stack className="d-flex mt-6 align-items-center">
<p className="lead"> <FormattedMessage {...title} /> </p>
<p className="small text-muted"> <FormattedMessage {...subtitle} /> </p>
<img src={image} alt="" />
<p className="lead"> <FormattedMessage {...props.title} /> </p>
<p className="small text-muted"> <FormattedMessage {...props.subtitle} /> </p>
<img src={props.image} alt="" />
</Stack>
);
@@ -21,9 +26,8 @@ const InfoMessage = ({ title, subtitle, image }) => (
* If the user hasn't put any keywords/filters yet, display an "empty state".
* Likewise, if the results are empty (0 results), display a special message.
* Otherwise, display the results, which are assumed to be the children prop.
* @type {React.FC<{children: React.ReactElement}>}
*/
const EmptyStates = ({ children }) => {
const EmptyStates: React.FC<{ children: React.ReactElement }> = ({ children }) => {
const {
canClearFilters: hasFiltersApplied,
totalHits,

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
@@ -17,9 +15,8 @@ import { useSearchContext } from './manager/SearchManager';
* 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.
* The button displays the first type selected, and a count of how many other types are selected, if more than one.
* @type {React.FC<Record<never, never>>}
*/
const FilterByBlockType = () => {
const FilterByBlockType: React.FC<Record<never, never>> = () => {
const {
blockTypes,
blockTypesFilter,
@@ -35,17 +32,17 @@ const FilterByBlockType = () => {
};
// If both blocktypes are in the order dictionary, sort them based on the order defined
if (order[a] && order[b]) {
if (a in order && b in order) {
return order[a] - order[b];
}
// If only blocktype 'a' is in the order dictionary, place it before 'b'
if (order[a]) {
if (a in order) {
return -1;
}
// If only blocktype 'b' is in the order dictionary, place it before 'a'
if (order[b]) {
if (b in order) {
return 1;
}
@@ -54,7 +51,7 @@ const FilterByBlockType = () => {
});
// Rebuild sorted blocktypes dictionary
const sortedBlockTypes = {};
const sortedBlockTypes: Record<string, number> = {};
sortedBlockTypeKeys.forEach(key => {
sortedBlockTypes[key] = blockTypes[key];
});
@@ -95,7 +92,7 @@ const FilterByBlockType = () => {
}
{
// Show a message if there are no options at all to avoid the impression that the dropdown isn't working
sortedBlockTypes.length === 0 ? (
Object.keys(sortedBlockTypes).length === 0 ? (
<MenuItem disabled><FormattedMessage {...messages['blockTypeFilter.empty']} /></MenuItem>
) : null
}

View File

@@ -1,5 +1,4 @@
/* eslint-disable react/prop-types */
// @ts-check
/* eslint-disable react/require-default-props */
import React from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -21,18 +20,17 @@ import { TAG_SEP } from './data/api';
/**
* A menu item with a checkbox and an optional button (to show/hide children)
* @type {React.FC<{
* label: string;
* tagPath: string;
* isChecked: boolean;
* onClickCheckbox: () => void;
* tagCount: number;
* hasChildren?: boolean;
* isExpanded?: boolean;
* onToggleChildren?: (tagPath: string) => void;
* }>}
*/
const TagMenuItem = ({
const TagMenuItem: React.FC<{
label: string;
tagPath: string;
isChecked: boolean;
onClickCheckbox: () => void;
tagCount: number;
hasChildren?: boolean;
isExpanded?: boolean;
onToggleChildren?: (tagPath: string) => void;
}> = ({
label,
tagPath,
tagCount,
@@ -83,14 +81,13 @@ const TagMenuItem = ({
/**
* A list of menu items with all of the options for tags at one level of the hierarchy.
* @type {React.FC<{
* tagSearchKeywords: string;
* parentTagPath?: string;
* toggleTagChildren?: (tagPath: string) => void;
* expandedTags: string[],
* }>}
*/
const TagOptions = ({
const TagOptions: React.FC<{
tagSearchKeywords: string;
parentTagPath?: string;
toggleTagChildren?: (tagPath: string) => void;
expandedTags: string[],
}> = ({
parentTagPath = '',
tagSearchKeywords,
expandedTags,
@@ -164,15 +161,14 @@ const TagOptions = ({
);
};
/** @type {React.FC} */
const FilterByTags = () => {
const FilterByTags: React.FC<Record<never, never>> = () => {
const intl = useIntl();
const { tagsFilter } = useSearchContext();
const [tagSearchKeywords, setTagSearchKeywords] = React.useState('');
// e.g. {"Location", "Location > North America"} if those two paths of the tag tree are expanded
const [expandedTags, setExpandedTags] = React.useState(/** @type {string[]} */([]));
const toggleTagChildren = React.useCallback(tagWithLineage => {
const [expandedTags, setExpandedTags] = React.useState<string[]>([]);
const toggleTagChildren = React.useCallback((tagWithLineage: string) => {
setExpandedTags(currentList => {
if (currentList.includes(tagWithLineage)) {
return currentList.filter(x => x !== tagWithLineage);

View File

@@ -1,15 +1,12 @@
/* eslint-disable react/no-array-index-key */
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { highlightPostTag, highlightPreTag } from './data/api';
/**
* Render some text that contains matching words which should be highlighted
* @type {React.FC<{text: string}>}
*/
const Highlight = ({ text }) => {
const Highlight: React.FC<{ text: string }> = ({ text }) => {
const parts = text.split(highlightPreTag);
return (
<span>

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { ArrowDropDown } from '@openedx/paragon/icons';
import {
@@ -19,10 +17,12 @@ import {
*
* When clicked, the button will display a dropdown menu containing this
* element's `children`. So use this to wrap a <RefinementList> etc.
*
* @type {React.FC<{appliedFilters: {label: React.ReactNode}[], label: React.ReactNode, children: React.ReactNode}>}
*/
const SearchFilterWidget = ({ appliedFilters, ...props }) => {
const SearchFilterWidget: React.FC<{
appliedFilters: { label: React.ReactNode }[];
label: React.ReactNode;
children: React.ReactNode;
}> = ({ appliedFilters, ...props }) => {
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = React.useState(null);

View File

@@ -1,5 +1,4 @@
/* eslint-disable react/prop-types */
// @ts-check
/* eslint-disable react/require-default-props */
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { SearchField } from '@openedx/paragon';
@@ -8,9 +7,8 @@ import { useSearchContext } from './manager/SearchManager';
/**
* The "main" input field where users type in search keywords. The search happens as they type (no need to press enter).
* @type {React.FC<{className?: string}>}
*/
const SearchKeywordsField = (props) => {
const SearchKeywordsField: React.FC<{ className?: string }> = (props) => {
const intl = useIntl();
const { searchKeywords, setSearchKeywords } = useSearchContext();

View File

@@ -6,14 +6,15 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import type { Store } from 'redux';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import SearchModal from './SearchModal';
import { getContentSearchConfigUrl } from './data/api';
let store;
let axiosMock;
let store: Store;
let axiosMock: MockAdapter;
const queryClient = new QueryClient({
defaultOptions: {
@@ -32,7 +33,7 @@ const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<SearchModal isOpen onClose={() => undefined} />
<SearchModal courseId="" isOpen onClose={() => undefined} />
</QueryClientProvider>
</IntlProvider>
</AppProvider>

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ModalDialog } from '@openedx/paragon';
@@ -7,8 +5,7 @@ import { ModalDialog } from '@openedx/paragon';
import messages from './messages';
import SearchUI from './SearchUI';
/** @type {React.FC<{courseId: string, isOpen: boolean, onClose: () => void}>} */
const SearchModal = ({ courseId, ...props }) => {
const SearchModal: React.FC<{ courseId: string, isOpen: boolean, onClose: () => void }> = ({ courseId, ...props }) => {
const intl = useIntl();
const title = intl.formatMessage(messages.title);

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { getConfig, getPath } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -20,47 +18,40 @@ import { constructLibraryAuthoringURL } from '../utils';
import { COMPONENT_TYPE_ICON_MAP, TYPE_ICONS_MAP } from '../course-unit/constants';
import { getStudioHomeData } from '../studio-home/data/selectors';
import { useSearchContext } from './manager/SearchManager';
import type { ContentHit } from './data/api';
import Highlight from './Highlight';
import messages from './messages';
const STRUCTURAL_TYPE_ICONS = {
const STRUCTURAL_TYPE_ICONS: Record<string, React.ReactElement> = {
vertical: TYPE_ICONS_MAP.vertical,
sequential: Folder,
chapter: Folder,
};
/** @param {string} blockType */
function getItemIcon(blockType) {
function getItemIcon(blockType: string): React.ReactElement {
return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article;
}
/**
* Returns the URL Suffix for library/library component hit
* @param {import('./data/api').ContentHit} hit
* @param {string} libraryAuthoringMfeUrl
* @returns string
*/
function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) {
function getLibraryHitUrl(hit: ContentHit, libraryAuthoringMfeUrl: string): string {
const { contextKey } = hit;
return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${contextKey}`);
}
/**
* Returns the URL Suffix for a unit hit
* @param {import('./data/api').ContentHit} hit
* @returns string
*/
function getUnitUrlSuffix(hit) {
function getUnitUrlSuffix(hit: ContentHit): string {
const { contextKey, usageKey } = hit;
return `course/${contextKey}/container/${usageKey}`;
}
/**
* Returns the URL Suffix for a unit component hit
* @param {import('./data/api').ContentHit} hit
* @returns string
*/
function getUnitComponentUrlSuffix(hit) {
function getUnitComponentUrlSuffix(hit: ContentHit): string {
const { breadcrumbs, contextKey, usageKey } = hit;
if (breadcrumbs.length > 1) {
let parent = breadcrumbs[breadcrumbs.length - 1];
@@ -86,20 +77,16 @@ function getUnitComponentUrlSuffix(hit) {
/**
* Returns the URL Suffix for a course component hit
* @param {import('./data/api').ContentHit} hit
* @returns string
*/
function getCourseComponentUrlSuffix(hit) {
function getCourseComponentUrlSuffix(hit: ContentHit): string {
const { contextKey, usageKey } = hit;
return `course/${contextKey}?show=${encodeURIComponent(usageKey)}`;
}
/**
* Returns the URL Suffix for the search hit param
* @param {import('./data/api').ContentHit} hit
* @returns string
*/
function getUrlSuffix(hit) {
function getUrlSuffix(hit: ContentHit): string {
const { blockType, breadcrumbs } = hit;
// Check if is a unit
@@ -123,9 +110,8 @@ function getUrlSuffix(hit) {
/**
* A single search result (row), usually represents an XBlock/Component
* @type {React.FC<{hit: import('./data/api').ContentHit}>}
*/
const SearchResult = ({ hit }) => {
const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
const intl = useIntl();
const navigate = useNavigate();
const { closeSearchModal } = useSearchContext();
@@ -162,10 +148,8 @@ const SearchResult = ({ hit }) => {
/**
* Opens the context of the hit in a new window
* @param {React.MouseEvent} e
* @returns {void}
*/
const openContextInNewWindow = (e) => {
const openContextInNewWindow = (e: React.MouseEvent): void => {
e.stopPropagation();
const newWindowUrl = getContextUrl(true);
/* istanbul ignore next */
@@ -177,10 +161,8 @@ const SearchResult = ({ hit }) => {
/**
* Navigates to the context of the hit
* @param {(React.MouseEvent | React.KeyboardEvent)} e
* @returns {void}
*/
const navigateToContext = React.useCallback((e) => {
const navigateToContext = React.useCallback((e: React.MouseEvent | React.KeyboardEvent): void => {
e.stopPropagation();
const redirectUrl = getContextUrl();

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { StatefulButton } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -13,10 +11,8 @@ import messages from './messages';
*
* Uses "infinite pagination" to load more pages as needed (if users click the
* "Show more results" button).
*
* @type {React.FC<Record<never, never>>}
*/
const SearchResults = () => {
const SearchResults: React.FC<Record<never, never>> = () => {
const intl = useIntl();
const {
hits,

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -13,8 +11,10 @@ import {
waitFor,
within,
getByLabelText as getByLabelTextIn,
type RenderResult,
} from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';
import type { Store } from 'redux';
import initializeStore from '../store';
import { executeThunk } from '../utils';
@@ -32,7 +32,7 @@ import { getContentSearchConfigUrl } from './data/api';
// mockResult contains only a single result - this one:
const mockResultDisplayName = 'Test HTML Block';
let store;
let store: Store;
const queryClient = new QueryClient();
@@ -51,8 +51,7 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
/** @type {React.FC<{children:React.ReactNode}>} */
const Wrap = ({ children }) => (
const Wrap: React.FC<{ children:React.ReactNode }> = ({ children }) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
@@ -61,9 +60,9 @@ const Wrap = ({ children }) => (
</IntlProvider>
</AppProvider>
);
let axiosMock;
let axiosMock: MockAdapter;
const returnEmptyResult = (_url, req) => {
const returnEmptyResult = (_url: string, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query,
@@ -71,7 +70,7 @@ const returnEmptyResult = (_url, req) => {
mockEmptyResult.results[0].query = query;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
mockEmptyResult.results[0]?.hits.forEach((hit: any) => { hit._formatted = { ...hit }; });
return mockEmptyResult;
};
@@ -188,8 +187,7 @@ describe('<SearchUI />', () => {
});
describe('results', () => {
/** @type {import('@testing-library/react').RenderResult} */
let rendered;
let rendered: RenderResult;
beforeEach(async () => {
rendered = render(<Wrap><SearchUI {...defaults} /></Wrap>);
const { getByRole } = rendered;
@@ -372,8 +370,7 @@ describe('<SearchUI />', () => {
});
describe('filters', () => {
/** @type {import('@testing-library/react').RenderResult} */
let rendered;
let rendered: RenderResult;
beforeEach(async () => {
fetchMock.post(facetSearchEndpoint, (_path, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');

View File

@@ -1,5 +1,4 @@
/* eslint-disable react/prop-types */
// @ts-check
/* eslint-disable react/require-default-props */
import React from 'react';
import {
MenuItem,
@@ -19,8 +18,7 @@ import Stats from './Stats';
import { SearchContextProvider } from './manager/SearchManager';
import messages from './messages';
/** @type {React.FC<{courseId: string, closeSearchModal?: () => void}>} */
const SearchUI = (props) => {
const SearchUI: React.FC<{ courseId: string, closeSearchModal?: () => void }> = (props) => {
const hasCourseId = Boolean(props.courseId);
const [searchThisCourseEnabled, setSearchThisCourse] = React.useState(hasCourseId);
const switchToThisCourse = React.useCallback(() => setSearchThisCourse(true), []);

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/prop-types */
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
@@ -7,9 +5,8 @@ import { useSearchContext } from './manager/SearchManager';
/**
* Simple component that displays the # of matching results
* @type {React.FC<Record<never, never>>}
*/
const Stats = () => {
const Stats: React.FC<Record<never, never>> = () => {
const { totalHits, searchKeywords, canClearFilters } = useSearchContext();
if (!searchKeywords && !canClearFilters) {

View File

@@ -1,6 +1,6 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type { Filter, MeiliSearch, MultiSearchQuery } from 'meilisearch';
export const getContentSearchConfigUrl = () => new URL(
'api/content_search/v2/studio/',
@@ -15,10 +15,8 @@ export const highlightPostTag = '__/meili-highlight__'; // Indicate the end of a
/**
* Get the content search configuration from the CMS.
*
* @returns {Promise<{url: string, indexName: string, apiKey: string}>}
*/
export const getContentSearchConfig = async () => {
export const getContentSearchConfig = async (): Promise<{ url: string, indexName: string, apiKey: string }> => {
const url = getContentSearchConfigUrl();
const response = await getAuthenticatedHttpClient().get(url);
return {
@@ -30,16 +28,19 @@ export const getContentSearchConfig = async () => {
/**
* Detailed "content" of an XBlock/component, from the block's index_dictionary function. Contents depends on the type.
* @typedef {{htmlContent?: string, capaContent?: string, [k: string]: any}} ContentDetails
*/
export interface ContentDetails {
htmlContent?: string;
capaContent?: string;
[k: string]: any;
}
/**
* Meilisearch filters can be expressed as strings or arrays.
* This helper method converts from any supported input format to an array, for consistency.
* @param {import('meilisearch').Filter} [filter] A filter expression, e.g. 'foo = bar' or [['a = b', 'a = c'], 'd = e']
* @returns {(string | string[])[]}
* @param filter A filter expression, e.g. `'foo = bar'` or `[['a = b', 'a = c'], 'd = e']`
*/
function forceArray(filter) {
function forceArray(filter?: Filter): (string | string[])[] {
if (typeof filter === 'string') {
return [filter];
}
@@ -52,12 +53,10 @@ function forceArray(filter) {
/**
* Given tag paths like ["Difficulty > Hard", "Subject > Math"], convert them to an array of Meilisearch
* filter conditions. The tag filters are all AND conditions (not OR).
* @param {string[]} [tagsFilter] e.g. ["Difficulty > Hard", "Subject > Math"]
* @returns {string[]}
* @param tagsFilter e.g. `["Difficulty > Hard", "Subject > Math"]`
*/
function formatTagsFilter(tagsFilter) {
/** @type {string[]} */
const filters = [];
function formatTagsFilter(tagsFilter?: string[]): string[] {
const filters: string[] = [];
tagsFilter?.forEach((tagPath) => {
const parts = tagPath.split(TAG_SEP);
@@ -74,29 +73,35 @@ function formatTagsFilter(tagsFilter) {
/**
* Information about a single XBlock returned in the search results
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
* @typedef {Object} ContentHit
* @property {string} id
* @property {string} usageKey
* @property {"course_block"|"library_block"} type
* @property {string} blockId
* @property {string} displayName
* @property {string} blockType The block_type part of the usage key. What type of XBlock this is.
* @property {string} contextKey The course or library ID
* @property {string} org
* @property {[{displayName: string}, ...Array<{displayName: string, usageKey: string}>]} breadcrumbs
* First one is the name of the course/library itself.
* After that is the name and usage key of any parent Section/Subsection/Unit/etc.
* @property {Record<'taxonomy'|'level0'|'level1'|'level2'|'level3', string[]>} tags
* @property {ContentDetails} [content]
* @property {{displayName: string, content: ContentDetails}} formatted Same fields with <mark>...</mark> highlights
*/
export interface ContentHit {
id: string;
usageKey: string;
type: 'course_block' | 'library_block';
blockId: string;
displayName: string;
/** The block_type part of the usage key. What type of XBlock this is. */
blockType: string;
/** The course or library ID */
contextKey: string;
org: string;
/**
* Breadcrumbs:
* - First one is the name of the course/library itself.
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
*/
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
tags: Record<'taxonomy' | 'level0' | 'level1' | 'level2' | 'level3', string[]>;
content?: ContentDetails;
/** Same fields with <mark>...</mark> highlights */
formatted: { displayName: string, content?: ContentDetails };
}
/**
* Convert search hits to camelCase
* @param {Record<string, any>} hit A search result directly from Meilisearch
* @returns {ContentHit}
* @param hit A search result directly from Meilisearch
*/
function formatSearchHit(hit) {
function formatSearchHit(hit: Record<string, any>): ContentHit {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { _formatted, ...newHit } = hit;
newHit.formatted = {
@@ -106,36 +111,33 @@ function formatSearchHit(hit) {
return camelCaseObject(newHit);
}
/**
* @param {{
* client: import('meilisearch').MeiliSearch,
* indexName: string,
* searchKeywords: string,
* blockTypesFilter?: string[],
* tagsFilter?: string[],
* extraFilter?: import('meilisearch').Filter,
* offset?: number,
* }} context
* @returns {Promise<{
* hits: ContentHit[],
* nextOffset: number|undefined,
* totalHits: number,
* blockTypes: Record<string, number>,
* }>}
*/
interface FetchSearchParams {
client: MeiliSearch,
indexName: string,
searchKeywords: string,
blockTypesFilter?: string[],
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
tagsFilter?: string[],
extraFilter?: Filter,
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
offset?: number,
}
export async function fetchSearchResults({
client,
indexName,
searchKeywords,
blockTypesFilter,
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
tagsFilter,
extraFilter,
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
offset = 0,
}) {
/** @type {import('meilisearch').MultiSearchQuery[]} */
const queries = [];
}: FetchSearchParams): Promise<{
hits: ContentHit[],
nextOffset: number | undefined,
totalHits: number,
blockTypes: Record<string, number>,
}> {
const queries: MultiSearchQuery[] = [];
// Convert 'extraFilter' into an array
const extraFilterFormatted = forceArray(extraFilter);
@@ -188,21 +190,17 @@ export async function fetchSearchResults({
};
}
/** Information about a single tag in the tag tree, as returned by fetchAvailableTagOptions() */
export interface TagEntry {
tagName: string;
tagPath: string;
tagCount: number;
hasChildren: boolean;
}
/**
* In the context of a particular search (which may already be filtered to a specific course, specific block types,
* and/or have a keyword search applied), get the tree of tags that can be used to further filter/refine the search.
*
* @param {object} context
* @param {import('meilisearch').MeiliSearch} context.client The Meilisearch client instance
* @param {string} context.indexName Which index to search
* @param {string} context.searchKeywords Overall query string for the search; may be empty
* @param {string[]} [context.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"]
* @param {import('meilisearch').Filter} [context.extraFilter] Any other filters to apply, e.g. course ID.
* @param {string} [context.parentTagPath] Only fetch tags below this parent tag/taxonomy e.g. "Places > North America"
* @returns {Promise<{
* tags: {tagName: string, tagPath: string, tagCount: number, hasChildren: boolean}[];
* mayBeMissingResults: boolean;
* }>}
*/
export async function fetchAvailableTagOptions({
client,
@@ -212,7 +210,20 @@ export async function fetchAvailableTagOptions({
extraFilter,
parentTagPath,
// Ideally this would include 'tagSearchKeywords' to filter the tag tree by keyword search but that's not possible yet
}) {
}: {
/** The Meilisearch client instance */
client: MeiliSearch;
/** Which index to search */
indexName: string;
/** Overall query string for the search; may be empty */
searchKeywords: string;
/** Filter to only include these block types e.g. ["problem", "html"] */
blockTypesFilter?: string[];
/** Any other filters to apply, e.g. course ID. */
extraFilter?: Filter;
/** Only fetch tags below this parent tag/taxonomy e.g. "Places > North America" */
parentTagPath?: string;
}): Promise<{ tags: TagEntry[]; mayBeMissingResults: boolean; }> {
const meilisearchFacetLimit = 100; // The 'maxValuesPerFacet' on the index. For Open edX we leave the default, 100.
// Convert 'extraFilter' into an array
@@ -224,8 +235,7 @@ export async function fetchAvailableTagOptions({
// e.g. "tags.taxonomy" is the facet/attribute that holds the root tags, and "tags.level0" has its child tags.
let facetName;
let depth;
/** @type {string[]} */
let parentFilter = [];
let parentFilter: string[] = [];
if (!parentTagPath) {
facetName = 'tags.taxonomy';
depth = 0;
@@ -251,8 +261,7 @@ export async function fetchAvailableTagOptions({
// Now load the facet values. Doing it with this API gives us much more flexibility in loading than if we just
// requested the facets by passing { facets: ["tags"] } into the main search request; that works fine for loading the
// root tags but can't load specific child tags like we can using this approach.
/** @type {{tagName: string, tagPath: string, tagCount: number, hasChildren: boolean}[]} */
const tags = [];
const tags: TagEntry[] = [];
const { facetHits } = await client.index(indexName).searchForFacetValues({
facetName,
// It's not super clear in the documentation, but facetQuery is basically a "startsWith" query, which is what we
@@ -299,8 +308,7 @@ export async function fetchAvailableTagOptions({
tags.forEach((t) => { t.hasChildren = true; });
} else if (childFacetHits.length > 0) {
// Some (or maybe all) of these tags have child tags. Let's figure out which ones exactly.
/** @type {Set<string>} */
const tagsWithChildren = new Set();
const tagsWithChildren = new Set<string>();
childFacetHits.forEach(({ value }) => {
// Trim the child tag off: 'Places > North America > New York' becomes 'Places > North America'
const tagPath = value.split(TAG_SEP).slice(0, -1).join(TAG_SEP);
@@ -324,14 +332,6 @@ export async function fetchAvailableTagOptions({
* are tagged with "Tag Alpha 1" and 10 XBlocks are tagged with "Tag Alpha 2", a search for "Alpha" may only return
* ["Tag Alpha 1"] instead of the correct result ["Tag Alpha 1", "Tag Alpha 2"] because we are limited to 1,000 matches,
* which may all have the same tags.
*
* @param {object} context
* @param {import('meilisearch').MeiliSearch} context.client The Meilisearch client instance
* @param {string} context.indexName Which index to search
* @param {string[]} [context.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"]
* @param {import('meilisearch').Filter} [context.extraFilter] Any other filters to apply to the overall search.
* @param {string} [context.tagSearchKeywords] Only show taxonomies/tags that match these keywords
* @returns {Promise<{ mayBeMissingResults: boolean; matches: {tagPath: string}[] }>}
*/
export async function fetchTagsThatMatchKeyword({
client,
@@ -339,7 +339,18 @@ export async function fetchTagsThatMatchKeyword({
blockTypesFilter,
extraFilter,
tagSearchKeywords,
}) {
}: {
/** The Meilisearch client instance */
client: MeiliSearch;
/** Which index to search */
indexName: string;
/** Filter to only include these block types e.g. `["problem", "html"]` */
blockTypesFilter?: string[];
/** Any other filters to apply to the overall search. */
extraFilter?: Filter;
/** Only show taxonomies/tags that match these keywords */
tagSearchKeywords?: string;
}): Promise<{ mayBeMissingResults: boolean; matches: { tagPath: string }[] }> {
if (!tagSearchKeywords || tagSearchKeywords.trim() === '') {
// This data isn't needed if there is no tag keyword search. Don't bother making a search query.
return { matches: [], mayBeMissingResults: false };
@@ -359,24 +370,27 @@ export async function fetchTagsThatMatchKeyword({
attributesToSearchOn: ['tags.taxonomy', 'tags.level0', 'tags.level1', 'tags.level2', 'tags.level3'],
attributesToRetrieve: ['tags'],
limit,
// We'd like to use 'showMatchesPosition: true' to know exaclty which tags match, but it doesn't provide the
// We'd like to use 'showMatchesPosition: true' to know exactly which tags match, but it doesn't provide the
// detail we need; it's impossible to tell which tag at a given level matched based on the returned _matchesPosition
// data - https://github.com/orgs/meilisearch/discussions/550
});
const tagSearchKeywordsLower = tagSearchKeywords.toLocaleLowerCase();
/** @type {Set<string>} */
const matches = new Set();
const matches = new Set<string>();
// We have data like this:
// hits: [
// {
// tags: { taxonomy: "Competency", "level0": "Competency > Abilities", "level1": "Competency > Abilities > ..." },
// tags: {
// taxonomy: ["Competency"],
// level0: ["Competency > Abilities"],
// level1: ["Competency > Abilities > ..."]
// }, ...
// }, ...
// ]
hits.forEach((hit) => {
Object.values(hit.tags).forEach((tagPathList) => {
Object.values(hit.tags).forEach((tagPathList: string[]) => {
tagPathList.forEach((tagPath) => {
if (tagPath.toLocaleLowerCase().includes(tagSearchKeywordsLower)) {
matches.add(tagPath);

View File

@@ -1,6 +1,6 @@
// @ts-check
import React from 'react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import type { Filter, MeiliSearch } from 'meilisearch';
import {
TAG_SEP,
@@ -29,13 +29,6 @@ export const useContentSearchConnection = () => (
/**
* Get the results of a search
* @param {object} context
* @param {import('meilisearch').MeiliSearch} [context.client] The Meilisearch API client
* @param {string} [context.indexName] Which search index contains the content data
* @param {import('meilisearch').Filter} [context.extraFilter] Other filters to apply to the search, e.g. course ID
* @param {string} context.searchKeywords The keywords that the user is searching for, if any
* @param {string[]} context.blockTypesFilter Only search for these block types (e.g. ["html", "problem"])
* @param {string[]} context.tagsFilter Required tags (all must match), e.g. ["Difficulty > Hard", "Subject > Math"]
*/
export const useContentSearchResults = ({
client,
@@ -44,6 +37,19 @@ export const useContentSearchResults = ({
searchKeywords,
blockTypesFilter,
tagsFilter,
}: {
/** The Meilisearch API client */
client?: MeiliSearch;
/** Which search index contains the content data */
indexName?: string;
/** Other filters to apply to the search, e.g. course ID */
extraFilter?: Filter;
/** The keywords that the user is searching for, if any */
searchKeywords: string;
/** Only search for these block types (e.g. `["html", "problem"]`) */
blockTypesFilter: string[];
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
tagsFilter: string[];
}) => {
const query = useInfiniteQuery({
enabled: client !== undefined && indexName !== undefined,
@@ -107,16 +113,23 @@ export const useContentSearchResults = ({
/**
* Get the available tags that can be used to refine a search, based on the search filters applied so far.
* Also the user can use a keyword search to find specific tags.
* @param {object} args
* @param {import('meilisearch').MeiliSearch} [args.client] The Meilisearch client instance
* @param {string} [args.indexName] Which index to search
* @param {string} args.searchKeywords Overall query string for the search; may be empty
* @param {string[]} [args.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"]
* @param {import('meilisearch').Filter} [args.extraFilter] Any other filters to apply to the overall search.
* @param {string} [args.tagSearchKeywords] Only show taxonomies/tags that match these keywords
* @param {string} [args.parentTagPath] Only fetch tags below this parent tag/taxonomy e.g. "Places > North America"
*/
export const useTagFilterOptions = (args) => {
export const useTagFilterOptions = (args: {
/** The Meilisearch client instance */
client?: MeiliSearch;
/** Which index to search */
indexName?: string;
/** Overall query string for the search; may be empty */
searchKeywords: string;
/** Filter to only include these block types e.g. `["problem", "html"]` */
blockTypesFilter?: string[];
/** Any other filters to apply to the overall search. */
extraFilter?: Filter;
/** Only show taxonomies/tags that match these keywords */
tagSearchKeywords?: string;
/** Only fetch tags below this parent tag/taxonomy e.g. `"Places > North America"` */
parentTagPath?: string;
}) => {
const mainQuery = useQuery({
enabled: args.client !== undefined && args.indexName !== undefined,
queryKey: [

View File

@@ -1,5 +1,4 @@
/* eslint-disable react/prop-types */
// @ts-check
/* eslint-disable react/require-default-props */
/**
* This is a search manager that provides search functionality similar to the
* Instantsearch library. We use it because Instantsearch doesn't support
@@ -7,44 +6,41 @@
* https://github.com/algolia/instantsearch/issues/1658
*/
import React from 'react';
import { MeiliSearch } from 'meilisearch';
import { MeiliSearch, type Filter } from 'meilisearch';
import { ContentHit } from '../data/api';
import { useContentSearchConnection, useContentSearchResults } from '../data/apiHooks';
/**
* @type {React.Context<undefined|{
* client?: MeiliSearch,
* indexName?: string,
* searchKeywords: string,
* setSearchKeywords: React.Dispatch<React.SetStateAction<string>>,
* blockTypesFilter: string[],
* setBlockTypesFilter: React.Dispatch<React.SetStateAction<string[]>>,
* tagsFilter: string[],
* setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>,
* blockTypes: Record<string, number>,
* extraFilter?: import('meilisearch').Filter,
* canClearFilters: boolean,
* clearFilters: () => void,
* hits: import('../data/api').ContentHit[],
* totalHits: number,
* isFetching: boolean,
* hasNextPage: boolean | undefined,
* isFetchingNextPage: boolean,
* fetchNextPage: () => void,
* closeSearchModal: () => void,
* hasError: boolean,
* }>}
*/
const SearchContext = /** @type {any} */(React.createContext(undefined));
export interface SearchContextData {
client?: MeiliSearch;
indexName?: string;
searchKeywords: string;
setSearchKeywords: React.Dispatch<React.SetStateAction<string>>;
blockTypesFilter: string[];
setBlockTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
tagsFilter: string[];
setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>;
blockTypes: Record<string, number>;
extraFilter?: Filter;
canClearFilters: boolean;
clearFilters: () => void;
hits: ContentHit[];
totalHits: number;
isFetching: boolean;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
closeSearchModal: () => void;
hasError: boolean;
}
/**
* @type {React.FC<{
* extraFilter?: import('meilisearch').Filter,
* children: React.ReactNode,
* closeSearchModal?: () => void,
* }>}
*/
export const SearchContextProvider = ({ extraFilter, children, closeSearchModal }) => {
const SearchContext = React.createContext<SearchContextData | undefined>(undefined);
export const SearchContextProvider: React.FC<{
extraFilter?: Filter;
children: React.ReactNode,
closeSearchModal?: () => void,
}> = ({ extraFilter, ...props }) => {
const [searchKeywords, setSearchKeywords] = React.useState('');
const [blockTypesFilter, setBlockTypesFilter] = React.useState(/** type {string[]} */([]));
const [tagsFilter, setTagsFilter] = React.useState(/** type {string[]} */([]));
@@ -88,11 +84,11 @@ export const SearchContextProvider = ({ extraFilter, children, closeSearchModal
extraFilter,
canClearFilters,
clearFilters,
closeSearchModal: closeSearchModal ?? (() => {}),
closeSearchModal: props.closeSearchModal ?? (() => {}),
hasError: hasConnectionError || result.isError,
...result,
},
}, children);
}, props.children);
};
export const useSearchContext = () => {

View File

@@ -1,8 +1,8 @@
// @ts-check
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
import type { defineMessages as defineMessagesType } from 'react-intl';
// frontend-platform currently doesn't provide types... do it ourselves.
const defineMessages = /** @type {import('react-intl').defineMessages} */(_defineMessages);
const defineMessages = _defineMessages as typeof defineMessagesType;
const messages = defineMessages({
blockTypeFilter: {