diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss
index 6e380bf9d2..de1c4e1ac0 100644
--- a/src/course-unit/CourseUnit.scss
+++ b/src/course-unit/CourseUnit.scss
@@ -4,3 +4,18 @@
@import "./course-xblock/CourseXBlock";
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";
+
+div.xblock-highlight {
+ animation: 5s glow;
+ animation-timing-function: cubic-bezier(1, 0, .72, .04);
+}
+
+@keyframes glow {
+ 0% {
+ box-shadow: 0 0 5px 5px $primary-500;
+ }
+
+ 100% {
+ box-shadow: unset;
+ }
+}
diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx
index 3f885692bc..a41c66af8a 100644
--- a/src/course-unit/course-xblock/CourseXBlock.jsx
+++ b/src/course-unit/course-xblock/CourseXBlock.jsx
@@ -6,7 +6,7 @@ import {
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
@@ -28,6 +28,10 @@ const CourseXBlock = ({
const courseId = useSelector(getCourseId);
const intl = useIntl();
+ const [searchParams] = useSearchParams();
+ const locatorId = searchParams.get('show');
+ const isScrolledToElement = locatorId === id;
+
const visibilityMessage = userPartitionInfo.selectedGroupsLabel
? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel })
: null;
@@ -61,13 +65,17 @@ const CourseXBlock = ({
useEffect(() => {
// if this item has been newly added, scroll to it.
- if (courseXBlockElementRef.current && shouldScroll) {
+ if (courseXBlockElementRef.current && (shouldScroll || isScrolledToElement)) {
scrollToElement(courseXBlockElementRef.current);
}
- }, []);
+ }, [isScrolledToElement]);
return (
-
+
{
const dispatch = useDispatch();
+ const [searchParams] = useSearchParams();
const [isErrorAlert, toggleErrorAlert] = useState(false);
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
@@ -76,7 +77,16 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const handleNavigate = (id) => {
if (sequenceId) {
- navigate(`/course/${courseId}/container/${blockId}/${id}`, { replace: true });
+ const path = `/course/${courseId}/container/${blockId}/${id}`;
+ const options = { replace: true };
+ if (searchParams.size) {
+ navigate({
+ pathname: path,
+ search: `?${searchParams}`,
+ }, options);
+ } else {
+ navigate(path, options);
+ }
}
};
diff --git a/src/custom.d.ts b/src/custom.d.ts
index cdb2b1a9a2..2b94311838 100644
--- a/src/custom.d.ts
+++ b/src/custom.d.ts
@@ -2,3 +2,8 @@ declare module '*.svg' {
const content: string;
export default content;
}
+
+declare module '*.json' {
+ const value: any;
+ export default value;
+}
diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx
index c15371d81b..725c4b1ab3 100644
--- a/src/search-modal/SearchResult.jsx
+++ b/src/search-modal/SearchResult.jsx
@@ -33,6 +33,80 @@ function getItemIcon(blockType) {
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) {
+ const { contextKey } = hit;
+ return `${libraryAuthoringMfeUrl}library/${contextKey}`;
+}
+
+/**
+ * Returns the URL Suffix for a unit hit
+ * @param {import('./data/api').ContentHit} hit
+ * @returns string
+*/
+function getUnitUrlSuffix(hit) {
+ 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) {
+ const { breadcrumbs, contextKey, usageKey } = hit;
+ if (breadcrumbs.length > 1) {
+ const parent = breadcrumbs[breadcrumbs.length - 1];
+
+ if ('usageKey' in parent) {
+ return `course/${contextKey}/container/${parent.usageKey}?show=${encodeURIComponent(usageKey)}`;
+ }
+ }
+
+ return `course/${contextKey}/`;
+}
+
+/**
+ * Returns the URL Suffix for a course component hit
+ * @param {import('./data/api').ContentHit} hit
+ * @returns string
+*/
+function getCourseComponentUrlSuffix(hit) {
+ 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) {
+ const { blockType, breadcrumbs } = hit;
+
+ // Check if is a unit
+ if (blockType === 'vertical') {
+ return getUnitUrlSuffix(hit);
+ }
+
+ // Check if the parent is a unit
+ if (breadcrumbs.length > 1) {
+ const parent = breadcrumbs[breadcrumbs.length - 1];
+
+ if ('usageKey' in parent && parent.usageKey.includes('type@vertical')) {
+ return getUnitComponentUrlSuffix(hit);
+ }
+ }
+
+ return getCourseComponentUrlSuffix(hit);
+}
+
/**
* A single search result (row), usually represents an XBlock/Component
* @type {React.FC<{hit: import('./data/api').ContentHit}>}
@@ -43,33 +117,34 @@ const SearchResult = ({ hit }) => {
const { closeSearchModal } = useSearchContext();
const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe } = useSelector(getStudioHomeData);
+ const { usageKey } = hit;
+
+ const noRedirectUrl = usageKey.startsWith('lb:') && !redirectToLibraryAuthoringMfe;
+
/**
* Returns the URL for the context of the hit
*/
const getContextUrl = React.useCallback((newWindow = false) => {
- const { contextKey, usageKey } = hit;
+ const { contextKey } = hit;
+
if (contextKey.startsWith('course-v1:')) {
- const courseSufix = `course/${contextKey}?show=${encodeURIComponent(usageKey)}`;
+ const urlSuffix = getUrlSuffix(hit);
+
if (newWindow) {
- return `${getPath(getConfig().PUBLIC_PATH)}${courseSufix}`;
+ return `${getPath(getConfig().PUBLIC_PATH)}${urlSuffix}`;
}
- return `/${courseSufix}`;
+ return `/${urlSuffix}`;
}
+
if (usageKey.startsWith('lb:')) {
if (redirectToLibraryAuthoringMfe) {
- return `${libraryAuthoringMfeUrl}library/${contextKey}`;
+ return getLibraryHitUrl(hit, libraryAuthoringMfeUrl);
}
}
// No context URL for this hit
return undefined;
- }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]);
-
- const redirectUrl = React.useMemo(() => getContextUrl(), [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]);
- const newWindowUrl = React.useMemo(
- () => getContextUrl(true),
- [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe],
- );
+ }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, hit]);
/**
* Opens the context of the hit in a new window
@@ -78,6 +153,7 @@ const SearchResult = ({ hit }) => {
*/
const openContextInNewWindow = (e) => {
e.stopPropagation();
+ const newWindowUrl = getContextUrl(true);
/* istanbul ignore next */
if (!newWindowUrl) {
return;
@@ -90,8 +166,9 @@ const SearchResult = ({ hit }) => {
* @param {(React.MouseEvent | React.KeyboardEvent)} e
* @returns {void}
*/
- const navigateToContext = (e) => {
+ const navigateToContext = React.useCallback((e) => {
e.stopPropagation();
+ const redirectUrl = getContextUrl();
/* istanbul ignore next */
if (!redirectUrl) {
@@ -112,16 +189,16 @@ const SearchResult = ({ hit }) => {
navigate(redirectUrl);
closeSearchModal();
- };
+ }, [getContextUrl]);
return (
@@ -140,7 +217,7 @@ const SearchResult = ({ hit }) => {
diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx
index 752bd7c584..89b68c20a2 100644
--- a/src/search-modal/SearchUI.test.jsx
+++ b/src/search-modal/SearchUI.test.jsx
@@ -17,17 +17,11 @@ import {
import fetchMock from 'fetch-mock-jest';
import initializeStore from '../store';
-// @ts-ignore
import mockResult from './__mocks__/search-result.json';
-// @ts-ignore
import mockEmptyResult from './__mocks__/empty-search-result.json';
-// @ts-ignore
import mockTagsFacetResult from './__mocks__/facet-search.json';
-// @ts-ignore
import mockTagsFacetResultLevel0 from './__mocks__/facet-search-level0.json';
-// @ts-ignore
import mockTagsFacetResultLevel1 from './__mocks__/facet-search-level1.json';
-// @ts-ignore
import mockTagsKeywordSearchResult from './__mocks__/tags-keyword-search.json';
import SearchUI from './SearchUI';
import { getContentSearchConfigUrl } from './data/api';
@@ -168,14 +162,18 @@ describe('', () => {
window.open = jest.fn();
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
expect(window.open).toHaveBeenCalledWith(
- '/course/course-v1:edx+TestCourse+24?show=block-v1%3Aedx%2BTestCourse%2B24%2Btype%40html%2Bblock%40test_html',
+ '/course/course-v1:edx+TestCourse+24/container/block-v1:edx+TestCourse+24+type@vertical+block@vertical_3_1'
+ + '?show=block-v1%3Aedx%2BTestCourse%2B24%2Btype%40html%2Bblock%40test_html',
'_blank',
);
window.open = open;
// Clicking in the result should navigate to the result's URL:
fireEvent.click(resultItem);
- expect(mockNavigate).toHaveBeenCalledWith('/course/course-v1:edx+TestCourse+24?show=block-v1%3Aedx%2BTestCourse%2B24%2Btype%40html%2Bblock%40test_html');
+ expect(mockNavigate).toHaveBeenCalledWith(
+ '/course/course-v1:edx+TestCourse+24/container/block-v1:edx+TestCourse+24+type@vertical+block@vertical_3_1'
+ + '?show=block-v1%3Aedx%2BTestCourse%2B24%2Btype%40html%2Bblock%40test_html',
+ );
});
it('defaults to searching "This Course" if used in a course', async () => {
diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json
index ff4397a406..5f6a675739 100644
--- a/src/search-modal/__mocks__/search-result.json
+++ b/src/search-modal/__mocks__/search-result.json
@@ -18,9 +18,15 @@
"org": "edx",
"breadcrumbs": [
{ "display_name": "TheCourse" },
- { "display_name": "Section 2" },
- { "display_name": "Subsection 3" },
- { "display_name": "The Little Unit That Could" }
+ { "display_name": "Section 2", "usage_key": "block-v1:edx+TestCourse+24+type@chapter+block@chapter_2" },
+ {
+ "display_name": "Subsection 3",
+ "usage_key": "block-v1:edx+TestCourse+24+type@sequential+block@sequential_3"
+ },
+ {
+ "display_name": "The Little Unit That Could",
+ "usage_key": "block-v1:edx+TestCourse+24+type@vertical+block@vertical_3_1"
+ }
],
"tags": {
"taxonomy": [
diff --git a/src/search-modal/data/api.js b/src/search-modal/data/api.js
index 126df5248f..e526546e69 100644
--- a/src/search-modal/data/api.js
+++ b/src/search-modal/data/api.js
@@ -83,8 +83,9 @@ function formatTagsFilter(tagsFilter) {
* @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}[]} breadcrumbs First one is the name of the course/library itself.
- * After that is the name of any parent Section/Subsection/Unit/etc.
+ * @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 ... highlights