diff --git a/package-lock.json b/package-lock.json index 2a16d33469..7c1d34967c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "lodash.camelcase": "4.3.0", "patch-package": "^8.0.0", "postcss-loader": "^8.1.1", + "openedx-search-api": "github:qasimgulzar/openedx-search-api#main", "prop-types": "15.8.1", "query-string": "^7.1.3", "react": "17.0.2", @@ -15438,6 +15439,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openedx-search-api": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/qasimgulzar/openedx-search-api.git#1c3ba4308a3d0c77ee5a6511be601e5b26eae166", + "license": "ISC" + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", diff --git a/package.json b/package.json index 799d0e2ba5..0b917936d5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "lodash.camelcase": "4.3.0", "patch-package": "^8.0.0", "postcss-loader": "^8.1.1", + "openedx-search-api": "github:qasimgulzar/openedx-search-api#main", "prop-types": "15.8.1", "query-string": "^7.1.3", "react": "17.0.2", diff --git a/src/course-home/courseware-search/CoursewareSearch.jsx b/src/course-home/courseware-search/CoursewareSearch.jsx index 4050aebf7c..9a72b884fd 100644 --- a/src/course-home/courseware-search/CoursewareSearch.jsx +++ b/src/course-home/courseware-search/CoursewareSearch.jsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router'; -import { useDispatch } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { @@ -16,9 +16,9 @@ import messages from './messages'; import CoursewareSearchForm from './CoursewareSearchForm'; import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter'; import { updateModel, useModel } from '../../generic/model-store'; -import { searchCourseContent } from '../data/thunks'; +import { searchCourseContent, generateSearchEngineAuthToken } from '../data/thunks'; -const CoursewareSearch = ({ intl, ...sectionProps }) => { +const CoursewareSearch = ({ searchEngineConfig, intl, ...sectionProps }) => { const { courseId } = useParams(); const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams(); const dispatch = useDispatch(); @@ -62,14 +62,20 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => { keyword: value, }); - dispatch(searchCourseContent(courseId, value)); + dispatch(searchCourseContent(courseId, value, searchEngineConfig)); setQuery(value); }; useEffect(() => { - handleSubmit(searchKeyword); + dispatch(generateSearchEngineAuthToken()); }, []); + useEffect(() => { + if (searchEngineConfig) { + handleSubmit(searchKeyword); + } + }, [searchEngineConfig]); + const handleOnChange = (value) => { if (value === searchKeyword) { return; } if (!value) { clearSearch(); } @@ -143,6 +149,11 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => { CoursewareSearch.propTypes = { intl: intlShape.isRequired, + searchEngineConfig: injectIntl.isRequired, }; -export default injectIntl(CoursewareSearch); +function mapStateToProps(state) { + const { searchEngineConfig } = state.models; + return { searchEngineConfig }; +} +export default injectIntl(connect(mapStateToProps)(CoursewareSearch)); diff --git a/src/course-home/courseware-search/map-search-response.js b/src/course-home/courseware-search/map-search-response.js index 49953b3f6b..b6ed4b9baa 100644 --- a/src/course-home/courseware-search/map-search-response.js +++ b/src/course-home/courseware-search/map-search-response.js @@ -39,17 +39,15 @@ export default function mapSearchResponse(response, searchKeywords = '') { const results = rawResults.map(result => { const { score, - data: { - id, - content: { - displayName, - htmlContent, - transcriptEn, - }, - contentType, - location, - url, + id, + content: { + displayName, + htmlContent, + transcriptEn, }, + contentType, + location, + url, } = result; const type = contentType?.toLowerCase() || defaultType; diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 22b6738df4..b232f9aa4a 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -1,6 +1,6 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { logInfo } from '@edx/frontend-platform/logging'; +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; import { appendBrowserTimezoneToUrl } from '../../utils'; const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => { @@ -465,3 +465,23 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option return camelCaseObject(response); } + +export async function getSearchEngineAuthToken() { + const authenticatedUser = getAuthenticatedUser(); + const url = new URL(`${getConfig().LMS_BASE_URL}/api/search/token/`); + + if (authenticatedUser) { + try { + const { data } = await getAuthenticatedHttpClient().get(url.href, {}); + return data; + } catch (e) { + const { customAttributes: { httpErrorStatus } } = e; + if (httpErrorStatus === 404) { + logInfo(`${e}. This probably happened because the search plugin is not installed on platform.`); + } else { + logError(e); + } + } + } + return null; +} diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js index ec0567f9cf..20d867cb43 100644 --- a/src/course-home/data/thunks.js +++ b/src/course-home/data/thunks.js @@ -1,5 +1,6 @@ import { logError } from '@edx/frontend-platform/logging'; -import { camelCaseObject } from '@edx/frontend-platform'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import SearchEngine from 'openedx-search-api'; import { executePostFromPostEvent, getCourseHomeCourseMetadata, @@ -13,11 +14,13 @@ import { postRequestCert, getLiveTabIframe, getCoursewareSearchEnabledFlag, - searchCourseContentFromAPI, + getSearchEngineAuthToken, } from './api'; import { - addModel, updateModel, + addModel, + updateModel, + setSearchEngineAuthToken, } from '../../generic/model-store'; import { @@ -166,10 +169,12 @@ export async function fetchCoursewareSearchSettings(courseId) { } } -export function searchCourseContent(courseId, searchKeyword) { +export function searchCourseContent(courseId, searchKeyword, config) { return async (dispatch) => { const start = new Date(); + const searchEngine = new SearchEngine(config.searchEngine, config, 'courseware_course_structure'); + dispatch(addModel({ modelType: 'contentSearchResults', model: { @@ -185,8 +190,21 @@ export function searchCourseContent(courseId, searchKeyword) { let curatedResponse; let errors; try { - ({ data } = await searchCourseContentFromAPI(courseId, searchKeyword)); - curatedResponse = mapSearchResponse(data, searchKeyword); + data = await searchEngine.search(searchKeyword, {}); + curatedResponse = mapSearchResponse({ + ...data, + results: data.results.map(hit => ({ + id: hit.item_id, + location: hit.location || [], + url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/${hit.usage_key}`, + contentType: hit.content_type, + content: { + displayName: hit?.content?.display_name, + htmlContent: hit?.content?.display_name, + transcriptEn: hit?.content?.display_name, + }, + })), + }, searchKeyword); } catch (e) { // TODO: Remove when publishing to prod. Just temporary for performance debugging. // eslint-disable-next-line no-console @@ -223,3 +241,10 @@ export function searchCourseContent(courseId, searchKeyword) { }); }; } + +export function generateSearchEngineAuthToken() { + return async (dispatch) => { + const response = await getSearchEngineAuthToken(); + dispatch(setSearchEngineAuthToken(camelCaseObject(response))); + }; +} diff --git a/src/generic/model-store/index.js b/src/generic/model-store/index.js index 954f5f3448..af1b745921 100644 --- a/src/generic/model-store/index.js +++ b/src/generic/model-store/index.js @@ -8,6 +8,7 @@ export { updateModelsMap, removeModel, removeModels, + setSearchEngineAuthToken, } from './slice'; export { diff --git a/src/generic/model-store/slice.js b/src/generic/model-store/slice.js index bc6dda3d6a..7f13207b6e 100644 --- a/src/generic/model-store/slice.js +++ b/src/generic/model-store/slice.js @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +import { camelCaseObject } from '@edx/frontend-platform'; function add(state, modelType, model, idField) { idField = idField ?? 'id'; @@ -63,6 +64,9 @@ const slice = createSlice({ const { modelType, ids } = payload; ids.forEach(id => remove(state, modelType, id)); }, + setSearchEngineAuthToken: (state, { payload }) => { + state.searchEngineConfig = camelCaseObject(payload); + }, }, }); @@ -75,6 +79,7 @@ export const { updateModelsMap, removeModel, removeModels, + setSearchEngineAuthToken, } = slice.actions; export const { reducer } = slice; diff --git a/webpack.dev.config.js b/webpack.dev.config.js index ddf63def17..4c77ed2d79 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -7,5 +7,6 @@ config.resolve.alias = { ...config.resolve.alias, '@src': path.resolve(__dirname, 'src'), }; +config.externalsType = 'script'; module.exports = config;