From 9901d9b8a5e920c0c0e1865381c687b5531df2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 24 Apr 2024 09:48:22 -0300 Subject: [PATCH 1/7] feat: redirect to unit page if the hit or its parent is a unit --- src/course-unit/CourseUnit.scss | 15 +++ .../course-xblock/CourseXBlock.jsx | 16 ++- src/course-unit/hooks.jsx | 14 ++- src/custom.d.ts | 5 + src/search-modal/SearchResult.jsx | 111 +++++++++++++++--- src/search-modal/SearchUI.test.jsx | 14 +-- src/search-modal/__mocks__/search-result.json | 12 +- src/search-modal/data/api.js | 5 +- 8 files changed, 156 insertions(+), 36 deletions(-) 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 From 1bbca0ad6a265cc66137f0e073b38b73f0cfc649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 24 Apr 2024 10:08:38 -0300 Subject: [PATCH 2/7] test: add comments --- src/search-modal/SearchResult.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 725c4b1ab3..620608b8be 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -69,7 +69,8 @@ function getUnitComponentUrlSuffix(hit) { } } - return `course/${contextKey}/`; + // istanbul ignore next - This case should never be reached + return `course/${contextKey}`; } /** @@ -142,7 +143,7 @@ const SearchResult = ({ hit }) => { } } - // No context URL for this hit + // No context URL for this hit (e.g. a library without library authoring mfe) return undefined; }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, hit]); From 949b3c6b359fc702c0fd1223a5cdd8a47371d958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 24 Apr 2024 11:36:48 -0300 Subject: [PATCH 3/7] test: improve test coverage --- src/search-modal/SearchUI.test.jsx | 130 +++++++++--- src/search-modal/__mocks__/search-result.json | 192 +++++++++++++++++- 2 files changed, 297 insertions(+), 25 deletions(-) diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx index 89b68c20a2..4fa79346f4 100644 --- a/src/search-modal/SearchUI.test.jsx +++ b/src/search-modal/SearchUI.test.jsx @@ -150,30 +150,10 @@ describe('', () => { // Now we should see the results: expect(queryByText('Enter a keyword')).toBeNull(); // The result: - expect(getByText('2 results found')).toBeInTheDocument(); + expect(getByText('6 results found')).toBeInTheDocument(); expect(getByText(mockResultDisplayName)).toBeInTheDocument(); // Breadcrumbs showing where the result came from: expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument(); - - const resultItem = getByRole('button', { name: /The Little Unit That Could/ }); - - // Clicking the "Open in new window" button should open the result in a new window: - const { open } = window; - 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/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/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 () => { @@ -196,12 +176,116 @@ describe('', () => { // Now we should see the results: expect(queryByText('Enter a keyword')).toBeNull(); // The result: - expect(getByText('2 results found')).toBeInTheDocument(); + expect(getByText('6 results found')).toBeInTheDocument(); expect(getByText(mockResultDisplayName)).toBeInTheDocument(); // Breadcrumbs showing where the result came from: expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument(); }); + describe('results', () => { + /** @type {import('@testing-library/react').RenderResult} */ + let rendered; + beforeEach(() => { + rendered = render(); + const { getByRole } = rendered; + fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); + }); + + test('click section result navigates to the context', async () => { + const { findAllByRole } = rendered; + + const [resultItem] = await findAllByRole('button', { name: /Section 1/ }); + + // Clicking the "Open in new window" button should open the result in a new window: + const { open } = window; + window.open = jest.fn(); + fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); + expect(window.open).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1' + + '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40chapter%2Bblock%40c7077c8cafcf420dbc0b440bf27bad04', + '_blank', + ); + window.open = open; + + // Clicking in the result should navigate to the result's URL: + fireEvent.click(resultItem); + expect(mockNavigate).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1' + + '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40chapter%2Bblock%40c7077c8cafcf420dbc0b440bf27bad04', + ); + }); + + test('click subsection result navigates to the context', async () => { + const { findAllByRole } = rendered; + + const [resultItem] = await findAllByRole('button', { name: /Subsection 1.1/ }); + + // Clicking the "Open in new window" button should open the result in a new window: + const { open } = window; + window.open = jest.fn(); + fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); + expect(window.open).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1' + + '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40sequential%2Bblock%4092e3e9ca156c44fa8a735f0e9e7c854f', + '_blank', + ); + window.open = open; + + // Clicking in the result should navigate to the result's URL: + fireEvent.click(resultItem); + expect(mockNavigate).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1' + + '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40sequential%2Bblock%4092e3e9ca156c44fa8a735f0e9e7c854f', + ); + }); + + test('click unit result navigates to the context', async () => { + const { findAllByRole } = rendered; + + const [resultItem] = await findAllByRole('button', { name: /Unit 1.1.1/ }); + + // Clicking the "Open in new window" button should open the result in a new window: + const { open } = window; + window.open = jest.fn(); + fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); + expect(window.open).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b', + '_blank', + ); + window.open = open; + + // Clicking in the result should navigate to the result's URL: + fireEvent.click(resultItem); + expect(mockNavigate).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b', + ); + }); + + test('click unit component result navigates to the context', async () => { + const { findAllByRole } = rendered; + + const [resultItem] = await findAllByRole('button', { name: /Announcement/ }); + + // Clicking the "Open in new window" button should open the result in a new window: + const { open } = window; + window.open = jest.fn(); + fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); + expect(window.open).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b' + + '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40html%2Bblock%400b2d1c0722f742489602b6d8645205f4', + '_blank', + ); + window.open = open; + + // Clicking in the result should navigate to the result's URL: + fireEvent.click(resultItem); + expect(mockNavigate).toHaveBeenCalledWith( + '/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b' + + '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40html%2Bblock%400b2d1c0722f742489602b6d8645205f4', + ); + }); + }); + describe('filters', () => { /** @type {import('@testing-library/react').RenderResult} */ let rendered; @@ -231,7 +315,7 @@ describe('', () => { return (requestedFilter?.length === 1); // the filter is: 'context_key = "course-v1:org+test+123"' }); // Now we should see the results: - expect(getByText('2 results found')).toBeInTheDocument(); + expect(getByText('6 results found')).toBeInTheDocument(); expect(getByText(mockResultDisplayName)).toBeInTheDocument(); }); diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json index 5f6a675739..6a4f184d71 100644 --- a/src/search-modal/__mocks__/search-result.json +++ b/src/search-modal/__mocks__/search-result.json @@ -74,13 +74,201 @@ } ], "type": "library_block" + }, + { + "display_name": "Section 1", + "block_id": "c7077c8cafcf420dbc0b440bf27bad04", + "content": {}, + "id": "block-v1sampletaxonomyorg1stc12023_1typechapterblockc7077c8cafcf420dbc0b440bf27bad04-2af9d1ac", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04", + "block_type": "chapter", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": 6, + "_formatted": { + "display_name": "Section 1", + "block_id": "c7077c8cafcf420dbc0b440bf27bad04", + "content": {}, + "id": "block-v1sampletaxonomyorg1stc12023_1typechapterblockc7077c8cafcf420dbc0b440bf27bad04-2af9d1ac", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04", + "block_type": "chapter", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": "6" + } + }, + { + "display_name": "Subsection 1.1", + "block_id": "92e3e9ca156c44fa8a735f0e9e7c854f", + "content": {}, + "id": "block-v1sampletaxonomyorg1stc12023_1typesequentialblock92e3e9ca156c44fa8a735f0e9e7c854f-ec0fb128", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + }, + { + "display_name": "Section 1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f", + "block_type": "sequential", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": 6, + "_formatted": { + "display_name": "Subsection 1.1", + "block_id": "92e3e9ca156c44fa8a735f0e9e7c854f", + "content": {}, + "id": "block-v1sampletaxonomyorg1stc12023_1typesequentialblock92e3e9ca156c44fa8a735f0e9e7c854f-ec0fb128", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + }, + { + "display_name": "Section 1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f", + "block_type": "sequential", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": "6" + } + }, + { + "display_name": "Unit 1.1.1", + "block_id": "aaf8b8eb86b54281aeeab12499d2cb0b", + "content": {}, + "id": "block-v1sampletaxonomyorg1stc12023_1typeverticalblockaaf8b8eb86b54281aeeab12499d2cb0b-afa27c6e", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + }, + { + "display_name": "Section 1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04" + }, + { + "display_name": "Subsection 1.1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b", + "block_type": "vertical", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": 6, + "_formatted": { + "display_name": "Unit 1.1.1", + "block_id": "aaf8b8eb86b54281aeeab12499d2cb0b", + "content": {}, + "id": "block-v1sampletaxonomyorg1stc12023_1typeverticalblockaaf8b8eb86b54281aeeab12499d2cb0b-afa27c6e", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + }, + { + "display_name": "Section 1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04" + }, + { + "display_name": "Subsection 1.1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b", + "block_type": "vertical", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": "6" + } + }, + { + "display_name": "Announcement", + "block_id": "0b2d1c0722f742489602b6d8645205f4", + "content": { + "html_content": "To use this template, replace the example text with your own text. When you add the component, be sure to select Settings to specify a Display Name and other values that apply. Announcement Date Short note that introduces the topic Instructor's name Heading for announcement 1 Announcement 1 text Heading for announcement 2 Announcement 2 text " + }, + "id": "block-v1sampletaxonomyorg1stc12023_1typehtmlblock0b2d1c0722f742489602b6d8645205f4-2db56dce", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + }, + { + "display_name": "Section 1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04" + }, + { + "display_name": "Subsection 1.1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f" + }, + { + "display_name": "Unit 1.1.1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@html+block@0b2d1c0722f742489602b6d8645205f4", + "block_type": "html", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": 6, + "_formatted": { + "display_name": "Announcement", + "block_id": "0b2d1c0722f742489602b6d8645205f4", + "content": { + "html_content": "To use this template, replace the example text with your own text. When you add the component, be sure to…" + }, + "id": "block-v1sampletaxonomyorg1stc12023_1typehtmlblock0b2d1c0722f742489602b6d8645205f4-2db56dce", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "Sample Taxonomy Course" + }, + { + "display_name": "Section 1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04" + }, + { + "display_name": "Subsection 1.1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f" + }, + { + "display_name": "Unit 1.1.1", + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b" + } + ], + "usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@html+block@0b2d1c0722f742489602b6d8645205f4", + "block_type": "html", + "context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1", + "org": "SampleTaxonomyOrg1", + "access_id": "6" + } } ], "query": "learn", "processingTimeMs": 1, - "limit": 2, + "limit": 6, "offset": 0, - "estimatedTotalHits": 2 + "estimatedTotalHits": 6 }, { "indexUid": "studio", From 78dc0c4af2254ee2642baefd14926ff22ae68833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 24 Apr 2024 15:04:13 -0300 Subject: [PATCH 4/7] test: add lib content tests --- src/search-modal/SearchResult.jsx | 4 +-- src/search-modal/SearchUI.test.jsx | 36 ++++++++++++++++++- src/search-modal/__mocks__/search-result.json | 2 +- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 620608b8be..7fb4cb2599 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -41,7 +41,7 @@ function getItemIcon(blockType) { */ function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) { const { contextKey } = hit; - return `${libraryAuthoringMfeUrl}library/${contextKey}`; + return `${libraryAuthoringMfeUrl}/library/${contextKey}`; } /** @@ -218,7 +218,7 @@ const SearchResult = ({ hit }) => { diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx index 4fa79346f4..1486643fec 100644 --- a/src/search-modal/SearchUI.test.jsx +++ b/src/search-modal/SearchUI.test.jsx @@ -17,6 +17,10 @@ import { import fetchMock from 'fetch-mock-jest'; import initializeStore from '../store'; +import { executeThunk } from '../utils'; +import { getStudioHomeApiUrl } from '../studio-home/data/api'; +import { fetchStudioHomeData } from '../studio-home/data/thunks'; +import { generateGetStudioHomeDataApiResponse } from '../studio-home/factories/mockApiResponses'; import mockResult from './__mocks__/search-result.json'; import mockEmptyResult from './__mocks__/empty-search-result.json'; import mockTagsFacetResult from './__mocks__/facet-search.json'; @@ -89,6 +93,7 @@ describe('', () => { index_name: 'studio', api_key: 'test-key', }); + // The Meilisearch client-side API uses fetch, not Axios. fetchMock.post(searchEndpoint, (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); @@ -185,7 +190,13 @@ describe('', () => { describe('results', () => { /** @type {import('@testing-library/react').RenderResult} */ let rendered; - beforeEach(() => { + beforeEach(async () => { + const data = generateGetStudioHomeDataApiResponse(); + data.redirectToLibraryAuthoringMfe = true; + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + + await executeThunk(fetchStudioHomeData(), store.dispatch); + rendered = render(); const { getByRole } = rendered; fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); @@ -284,6 +295,29 @@ describe('', () => { + '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40html%2Bblock%400b2d1c0722f742489602b6d8645205f4', ); }); + + test('click lib component result navigates to the context', async () => { + const { findByRole } = rendered; + + const resultItem = await findByRole('button', { name: /Library Content/ }); + + // Clicking the "Open in new window" button should open the result in a new window: + const { open, location } = window; + window.open = jest.fn(); + fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); + expect(window.open).toHaveBeenCalledWith( + 'http://localhost:3001/library/lib:org1:libafter1', + '_blank', + ); + window.open = open; + + // @ts-ignore + window.location = { href: '' }; + // Clicking in the result should navigate to the result's URL: + fireEvent.click(resultItem); + expect(window.location.href = 'http://localhost:3001/library/lib:org1:libafter1'); + window.location = location; + }); }); describe('filters', () => { diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json index 6a4f184d71..5371f3d5c7 100644 --- a/src/search-modal/__mocks__/search-result.json +++ b/src/search-modal/__mocks__/search-result.json @@ -58,7 +58,7 @@ } }, { - "display_name": "Text1", + "display_name": "Library Content", "block_id": "a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d", "content": { "html_content": " Test " From f873a9880d2cf9183810cfe31c266b9ea2120af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 24 Apr 2024 15:19:36 -0300 Subject: [PATCH 5/7] test: more lib content tests --- src/search-modal/SearchUI.test.jsx | 38 +++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx index 1486643fec..0d5aeb697e 100644 --- a/src/search-modal/SearchUI.test.jsx +++ b/src/search-modal/SearchUI.test.jsx @@ -191,12 +191,6 @@ describe('', () => { /** @type {import('@testing-library/react').RenderResult} */ let rendered; beforeEach(async () => { - const data = generateGetStudioHomeDataApiResponse(); - data.redirectToLibraryAuthoringMfe = true; - axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); - - await executeThunk(fetchStudioHomeData(), store.dispatch); - rendered = render(); const { getByRole } = rendered; fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); @@ -297,6 +291,12 @@ describe('', () => { }); test('click lib component result navigates to the context', async () => { + const data = generateGetStudioHomeDataApiResponse(); + data.redirectToLibraryAuthoringMfe = true; + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + + await executeThunk(fetchStudioHomeData(), store.dispatch); + const { findByRole } = rendered; const resultItem = await findByRole('button', { name: /Library Content/ }); @@ -318,6 +318,32 @@ describe('', () => { expect(window.location.href = 'http://localhost:3001/library/lib:org1:libafter1'); window.location = location; }); + + test('click lib component result doesnt navigates to the context withou libraryAuthoringMfe', async () => { + const data = generateGetStudioHomeDataApiResponse(); + data.redirectToLibraryAuthoringMfe = false; + axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data); + + await executeThunk(fetchStudioHomeData(), store.dispatch); + + const { findByRole } = rendered; + + const resultItem = await findByRole('button', { name: /Library Content/ }); + + // Clicking the "Open in new window" button should open the result in a new window: + const { open, location } = window; + window.open = jest.fn(); + fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' })); + expect(window.open).not.toHaveBeenCalled(); + window.open = open; + + // @ts-ignore + window.location = { href: '' }; + // Clicking in the result should navigate to the result's URL: + fireEvent.click(resultItem); + expect(window.location.href === location.href); + window.location = location; + }); }); describe('filters', () => { From a13ccb554f84c2b2fb4f61b3e34ace4362c23b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 24 Apr 2024 16:45:11 -0300 Subject: [PATCH 6/7] fix: merge error --- src/course-unit/course-xblock/CourseXBlock.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 75c99caf1d..c84f6d776c 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -6,7 +6,6 @@ import { } from '@openedx/paragon'; 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, useSearchParams } from 'react-router-dom'; import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors'; From 0493375bc5880a124d12fc98fc806960a9f3f5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 29 Apr 2024 09:53:21 -0300 Subject: [PATCH 7/7] refactor: moving css style --- src/course-outline/CourseOutline.scss | 15 --------------- src/course-unit/CourseUnit.scss | 15 --------------- src/index.scss | 22 ++++++++++++++++++++++ 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 19bdb37c40..316de58688 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -9,18 +9,3 @@ @import "./publish-modal/PublishModal"; @import "./drag-helper/SortableItem"; @import "./xblock-status/XBlockStatus"; - -div.row:has(> div > div.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/CourseUnit.scss b/src/course-unit/CourseUnit.scss index de1c4e1ac0..6e380bf9d2 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -4,18 +4,3 @@ @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/index.scss b/src/index.scss index bab24de4c9..41984ac61d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -28,3 +28,25 @@ @import "search-modal/SearchModal"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; + +// To apply the glow effect to the selected Section/Subsection, in the Course Outline +div.row:has(> div > div.highlight) { + animation: 5s glow; + animation-timing-function: cubic-bezier(1, 0, .72, .04); +} + +// To apply the glow effect to the selected xblock, in the Unit Outline +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; + } +}