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:
9
package-lock.json
generated
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`];
|
||||
|
||||
@@ -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 (
|
||||
@@ -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,
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
@@ -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() ?? '');
|
||||
@@ -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), []);
|
||||
@@ -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) {
|
||||
@@ -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);
|
||||
@@ -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: [
|
||||
@@ -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 = () => {
|
||||
@@ -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: {
|
||||
Reference in New Issue
Block a user