diff --git a/src/constants.js b/src/constants.js index 163a16ef84..9e5c5cc84d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -58,6 +58,7 @@ export const COURSE_BLOCK_NAMES = ({ chapter: { id: 'chapter', name: 'Section' }, sequential: { id: 'sequential', name: 'Subsection' }, vertical: { id: 'vertical', name: 'Unit' }, + libraryContent: { id: 'library_content', name: 'Library content' }, component: { id: 'component', name: 'Component' }, }); diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 4c54d6775e..e4b33f7a98 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -28,7 +28,7 @@ import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import HeaderNavigations from './header-navigations/HeaderNavigations'; import Sequence from './course-sequence'; import Sidebar from './sidebar'; -import { useCourseUnit } from './hooks'; +import { useCourseUnit, useLayoutGrid } from './hooks'; import messages from './messages'; import PublishControls from './sidebar/PublishControls'; import LocationInfo from './sidebar/LocationInfo'; @@ -45,10 +45,13 @@ const CourseUnit = ({ courseId }) => { isLoading, sequenceId, unitTitle, + unitCategory, errorMessage, sequenceStatus, savingStatus, isTitleEditFormOpen, + isUnitVerticalType, + isUnitLibraryType, staticFileNotices, currentlyVisibleToStudents, unitXBlockActions, @@ -70,6 +73,7 @@ const CourseUnit = ({ courseId }) => { handleCloseXBlockMovedAlert, handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); + const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType); useEffect(() => { document.title = getPageHeadTitle('', unitTitle); @@ -142,28 +146,28 @@ const CourseUnit = ({ courseId }) => { /> )} breadcrumbs={( - + )} headerActions={( )} /> - - + {isUnitVerticalType && ( + + )} + {currentlyVisibleToStudents && ( { unitXBlockActions={unitXBlockActions} courseVerticalChildren={courseVerticalChildren.children} /> - - {showPasteXBlock && canPasteComponent && ( + {isUnitVerticalType && ( + + )} + {showPasteXBlock && canPasteComponent && isUnitVerticalType && ( { - - - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' - && ( - - - + {isUnitVerticalType && ( + <> + + + + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + + + )} + + + + )} - - - diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index a2d6124ba3..abc649b986 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -6,6 +6,10 @@ @import "./move-modal"; @import "./preview-changes"; +.course-unit { + min-width: 900px; +} + .course-unit__alert { margin-bottom: 1.75rem; } diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 63fb3bf1d1..5bef76c64a 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -52,6 +52,7 @@ import sidebarMessages from './sidebar/messages'; import { extractCourseUnitId } from './sidebar/utils'; import CourseUnit from './CourseUnit'; +import { getClipboardUrl } from '../generic/data/api'; import configureModalMessages from '../generic/configure-modal/messages'; import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; import addComponentMessages from './add-component/messages'; @@ -114,6 +115,22 @@ const clipboardBroadcastChannelMock = { global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); +/** + * Simulates receiving a post message event for testing purposes. + * This can be used to mimic events like deletion or other actions + * sent from Backbone or other sources via postMessage. + * + * @param {string} type - The type of the message event (e.g., 'deleteXBlock'). + * @param {Object} payload - The payload data for the message event. + */ +function simulatePostMessageEvent(type, payload) { + const messageEvent = new MessageEvent('message', { + data: { type, payload }, + }); + + window.dispatchEvent(messageEvent); +} + const RootWrapper = () => ( @@ -138,6 +155,9 @@ describe('', () => { global.localStorage.clear(); store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); axiosMock .onGet(getCourseUnitApiUrl(courseId)) .reply(200, courseUnitIndexMock); @@ -226,6 +246,19 @@ describe('', () => { display_name: newDisplayName, }, }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + display_name: newDisplayName, + }, + xblock: { + ...courseSectionVerticalMock.xblock, + display_name: newDisplayName, + }, + }); await waitFor(() => { const unitHeaderTitle = getByTestId('unit-header-title'); @@ -914,9 +947,7 @@ describe('', () => { .reply(200, clipboardMockResponse); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...updatedCourseSectionVerticalData, - }); + .reply(200, updatedCourseSectionVerticalData); global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); @@ -1190,7 +1221,7 @@ describe('', () => { axiosMock .onGet(getCourseUnitApiUrl(blockId)) - .reply(200, {}); + .reply(200, courseUnitIndexMock); await act(async () => { await waitFor(() => { @@ -1324,4 +1355,61 @@ describe('', () => { ); }); }); + + describe('Library Content page', () => { + const newUnitId = '12345'; + const sequenceId = courseSectionVerticalMock.subsection_location; + + beforeEach(async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock: { + ...courseSectionVerticalMock.xblock, + category: 'library_content', + }, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + category: 'library_content', + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + }); + + it('navigates to library content page on receive window event', () => { + render(); + + simulatePostMessageEvent(messageTypes.handleViewXBlockContent, { usageId: newUnitId }); + expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${newUnitId}/${sequenceId}`); + }); + + it('should render library content page correctly', async () => { + const { + getByText, + getByRole, + queryByRole, + getByTestId, + } = render(); + + const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; + const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; + + await waitFor(() => { + const unitHeaderTitle = getByTestId('unit-header-title'); + expect(getByText(unitDisplayName)).toBeInTheDocument(); + expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); + expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); + expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); + + expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument(); + + expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument(); + expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index 70962a7ac6..598b82b59f 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -23,7 +23,7 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false); const [isOpenHtml, openHtml, closeHtml] = useToggle(false); const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false); - const { componentTemplates } = useSelector(getCourseSectionVertical); + const { componentTemplates = {} } = useSelector(getCourseSectionVertical); const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle(); const [selectedComponents, setSelectedComponents] = useState([]); diff --git a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx index 91cc5b09b1..030e50b2ea 100644 --- a/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx +++ b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import { Icon } from '@openedx/paragon'; import { EditNote as EditNoteIcon } from '@openedx/paragon/icons'; -import { COMPONENT_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../../generic/block-type-utils/constants'; +import { COMPONENT_TYPE_ICON_MAP } from '../../../generic/block-type-utils/constants'; const AddComponentIcon = ({ type }) => { const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon; @@ -11,7 +11,7 @@ const AddComponentIcon = ({ type }) => { }; AddComponentIcon.propTypes = { - type: PropTypes.oneOf(Object.values(COMPONENT_TYPES)).isRequired, + type: PropTypes.string.isRequired, }; export default AddComponentIcon; diff --git a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx index 84fbc16115..dcbd9e45c3 100644 --- a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx +++ b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx @@ -72,7 +72,7 @@ const ComponentModalView = ({ + {supportLabels[componentTemplate.supportLevel].tooltip} )} diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.jsx b/src/course-unit/breadcrumbs/Breadcrumbs.jsx deleted file mode 100644 index 26bfa53562..0000000000 --- a/src/course-unit/breadcrumbs/Breadcrumbs.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Dropdown, Icon } from '@openedx/paragon'; -import { Link } from 'react-router-dom'; -import { - ArrowDropDown as ArrowDropDownIcon, - ChevronRight as ChevronRightIcon, -} from '@openedx/paragon/icons'; -import { getConfig } from '@edx/frontend-platform'; - -import { getWaffleFlags } from '../../data/selectors'; -import { getCourseSectionVertical } from '../data/selectors'; -import messages from './messages'; - -const Breadcrumbs = () => { - const intl = useIntl(); - const { ancestorXblocks } = useSelector(getCourseSectionVertical); - const [section, subsection] = ancestorXblocks ?? []; - const waffleFlags = useSelector(getWaffleFlags); - - const getPathToCourseOutlinePage = (url) => (waffleFlags.useNewCourseOutlinePage - ? url : `${getConfig().STUDIO_BASE_URL}${url}`); - - return ( - - ); -}; - -export default Breadcrumbs; diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.test.jsx b/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx similarity index 74% rename from src/course-unit/breadcrumbs/Breadcrumbs.test.jsx rename to src/course-unit/breadcrumbs/Breadcrumbs.test.tsx index d20a35c339..e4c93deb8f 100644 --- a/src/course-unit/breadcrumbs/Breadcrumbs.test.jsx +++ b/src/course-unit/breadcrumbs/Breadcrumbs.test.tsx @@ -15,6 +15,7 @@ import Breadcrumbs from './Breadcrumbs'; let axiosMock; let reduxStore; const courseId = '123'; +const parentUnitId = '456'; const mockNavigate = jest.fn(); const breadcrumbsExpected = { section: { @@ -32,7 +33,7 @@ jest.mock('react-router-dom', () => ({ })); const renderComponent = () => render( - , + , ); describe('', () => { @@ -69,6 +70,39 @@ describe('', () => { }); }); + it('render Breadcrumbs with many ancestors items correctly', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(courseId)) + .reply(200, { + ...courseSectionVerticalMock, + ancestor_xblocks: [ + { + children: [ + { + ...courseSectionVerticalMock.ancestor_xblocks[0], + display_name: 'Some module unit 1', + }, + { + ...courseSectionVerticalMock.ancestor_xblocks[1], + display_name: 'Some module unit 2', + }, + ], + title: 'Some module', + is_last: false, + }, + ...courseSectionVerticalMock.ancestor_xblocks, + ], + }); + await executeThunk(fetchCourseSectionVerticalData(courseId), reduxStore.dispatch); + const { getByText } = renderComponent(); + + await waitFor(() => { + expect(getByText('Some module')).toBeInTheDocument(); + expect(getByText(breadcrumbsExpected.section.displayName)).toBeInTheDocument(); + expect(getByText(breadcrumbsExpected.subsection.displayName)).toBeInTheDocument(); + }); + }); + it('render Breadcrumbs\'s dropdown menus correctly', async () => { const { getByText, queryAllByTestId } = renderComponent(); @@ -80,11 +114,13 @@ describe('', () => { const button = getByText(breadcrumbsExpected.section.displayName); userEvent.click(button); await waitFor(() => { - expect(queryAllByTestId('breadcrumbs-section-dropdown-item')).toHaveLength(5); + expect(queryAllByTestId('breadcrumbs-dropdown-item-level-0')).toHaveLength(5); }); userEvent.click(getByText(breadcrumbsExpected.subsection.displayName)); - expect(queryAllByTestId('breadcrumbs-subsection-dropdown-item')).toHaveLength(2); + await waitFor(() => { + expect(queryAllByTestId('breadcrumbs-dropdown-item-level-1')).toHaveLength(2); + }); }); it('navigates using the new course outline page when the waffle flag is enabled', async () => { @@ -118,6 +154,6 @@ describe('', () => { userEvent.click(dropdownBtn); const dropdownItem = getByRole('link', { name: display_name }); - expect(dropdownItem.href).toBe(`${getConfig().STUDIO_BASE_URL}${url}`); + expect(dropdownItem).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${url}`); }); }); diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.tsx b/src/course-unit/breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000000..367d0a5643 --- /dev/null +++ b/src/course-unit/breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,80 @@ +import { useSelector } from 'react-redux'; +import { Dropdown, Icon } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; +import { + ArrowDropDown as ArrowDropDownIcon, + ChevronRight as ChevronRightIcon, +} from '@openedx/paragon/icons'; +import { getConfig } from '@edx/frontend-platform'; + +import { getWaffleFlags } from '../../data/selectors'; +import { getCourseSectionVertical } from '../data/selectors'; +import { adoptCourseSectionUrl } from '../utils'; + +const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitId: string }) => { + const { ancestorXblocks = [] } = useSelector(getCourseSectionVertical); + const waffleFlags = useSelector(getWaffleFlags); + + const getPathToCourseOutlinePage = (url) => (waffleFlags.useNewCourseOutlinePage + ? url : `${getConfig().STUDIO_BASE_URL}${url}`); + + const getPathToCourseUnitPage = (url) => (waffleFlags.useNewUnitPage + ? adoptCourseSectionUrl({ url, courseId, parentUnitId }) + : `${getConfig().STUDIO_BASE_URL}${url}`); + + const getPathToCoursePage = (isOutlinePage, url) => ( + isOutlinePage ? getPathToCourseOutlinePage(url) : getPathToCourseUnitPage(url) + ); + + return ( + + ); +}; + +export default Breadcrumbs; diff --git a/src/course-unit/clipboard/paste-notification/components/FileList.jsx b/src/course-unit/clipboard/paste-notification/components/FileList.jsx index f3f9e3beaa..148b622539 100644 --- a/src/course-unit/clipboard/paste-notification/components/FileList.jsx +++ b/src/course-unit/clipboard/paste-notification/components/FileList.jsx @@ -5,7 +5,7 @@ import { FILE_LIST_DEFAULT_VALUE } from '../constants'; const FileList = ({ fileList }) => (
    {fileList.map((fileName) => ( -
  • {fileName}
  • +
  • {fileName}
  • ))}
); diff --git a/src/course-unit/clipboard/paste-notification/index.jsx b/src/course-unit/clipboard/paste-notification/index.jsx index b92334c717..20eed888df 100644 --- a/src/course-unit/clipboard/paste-notification/index.jsx +++ b/src/course-unit/clipboard/paste-notification/index.jsx @@ -101,7 +101,7 @@ const PastNotificationAlert = ({ staticFileNotices, courseId }) => { PastNotificationAlert.propTypes = { courseId: PropTypes.string.isRequired, staticFileNotices: - PropTypes.objectOf({ + PropTypes.shape({ conflictingFiles: PropTypes.arrayOf(PropTypes.string), errorFiles: PropTypes.arrayOf(PropTypes.string), newFiles: PropTypes.arrayOf(PropTypes.string), diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index 129fa55d9b..aaf5e728aa 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -55,6 +55,7 @@ export const messageTypes = { showMultipleComponentPicker: 'showMultipleComponentPicker', addSelectedComponentsToBank: 'addSelectedComponentsToBank', showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview', + handleViewXBlockContent: 'handleViewXBlockContent', }; export const IFRAME_FEATURE_POLICY = ( diff --git a/src/course-unit/context/iFrameContext.tsx b/src/course-unit/context/iFrameContext.tsx index 75418f0d39..ab216bb79a 100644 --- a/src/course-unit/context/iFrameContext.tsx +++ b/src/course-unit/context/iFrameContext.tsx @@ -1,4 +1,4 @@ -import { +import React, { createContext, MutableRefObject, useRef, useCallback, useMemo, ReactNode, } from 'react'; import { logError } from '@edx/frontend-platform/logging'; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 039285dcf4..7a46974060 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -99,6 +99,7 @@ export async function createCourseXblock({ * @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges). * @param {boolean} isVisible - The visibility status for students. * @param {boolean} groupAccess - Access group key set. + * @param {boolean} isDiscussionEnabled - Indicates whether the discussion feature is enabled. * @returns {Promise} A promise that resolves with the response data. */ export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess, isDiscussionEnabled) { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index a0d421eea3..99dac4b690 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -70,11 +70,11 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) { dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateModel({ modelType: 'sequences', - model: courseSectionVerticalData.sequence, + model: courseSectionVerticalData.sequence || [], })); dispatch(updateModels({ modelType: 'units', - models: courseSectionVerticalData.units, + models: courseSectionVerticalData.units || [], })); dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices')))); localStorage.removeItem('staticFileNotices'); @@ -103,11 +103,11 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) { dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateModel({ modelType: 'sequences', - model: courseSectionVerticalData.sequence, + model: courseSectionVerticalData.sequence || [], })); dispatch(updateModels({ modelType: 'units', - models: courseSectionVerticalData.units, + models: courseSectionVerticalData.units || [], })); dispatch(fetchSequenceSuccess({ sequenceId })); dispatch(fetchCourseItemSuccess(courseUnit)); diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js index def2b38492..0b28805297 100644 --- a/src/course-unit/data/utils.js +++ b/src/course-unit/data/utils.js @@ -10,9 +10,9 @@ export function normalizeCourseSectionVerticalData(metadata) { sequence: { id: data.subsectionLocation, title: data.xblock.displayName, - unitIds: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((item) => item.id), + unitIds: data.xblockInfo.ancestorInfo?.ancestors[0].childInfo.children.map((item) => item.id), }, - units: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((unit) => ({ + units: data.xblockInfo.ancestorInfo?.ancestors[0].childInfo.children.map((unit) => ({ id: unit.id, sequenceId: data.subsectionLocation, bookmarked: unit.bookmarked, diff --git a/src/course-unit/header-navigations/HeaderNavigations.jsx b/src/course-unit/header-navigations/HeaderNavigations.jsx index 178c768dfd..a934c0c974 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.jsx +++ b/src/course-unit/header-navigations/HeaderNavigations.jsx @@ -1,27 +1,42 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; +import { Edit as EditIcon } from '@openedx/paragon/icons'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; -const HeaderNavigations = ({ headerNavigationsActions }) => { +const HeaderNavigations = ({ headerNavigationsActions, unitCategory }) => { const intl = useIntl(); - const { handleViewLive, handlePreview } = headerNavigationsActions; + const { handleViewLive, handlePreview, handleEdit } = headerNavigationsActions; return ( ); }; @@ -30,7 +45,9 @@ HeaderNavigations.propTypes = { headerNavigationsActions: PropTypes.shape({ handleViewLive: PropTypes.func.isRequired, handlePreview: PropTypes.func.isRequired, + handleEdit: PropTypes.func.isRequired, }).isRequired, + unitCategory: PropTypes.string.isRequired, }; export default HeaderNavigations; diff --git a/src/course-unit/header-navigations/HeaderNavigations.test.jsx b/src/course-unit/header-navigations/HeaderNavigations.test.jsx index e5a094247e..1c93905cec 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.test.jsx +++ b/src/course-unit/header-navigations/HeaderNavigations.test.jsx @@ -1,14 +1,18 @@ import { fireEvent, render } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import HeaderNavigations from './HeaderNavigations'; import messages from './messages'; const handleViewLiveFn = jest.fn(); const handlePreviewFn = jest.fn(); +const handleEditFn = jest.fn(); + const headerNavigationsActions = { handleViewLive: handleViewLiveFn, handlePreview: handlePreviewFn, + handleEdit: handleEditFn, }; const renderComponent = (props) => render( @@ -22,14 +26,14 @@ const renderComponent = (props) => render( describe('', () => { it('render HeaderNavigations component correctly', () => { - const { getByRole } = renderComponent(); + const { getByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.previewButton.defaultMessage })).toBeInTheDocument(); }); - it('calls the correct handlers when clicking buttons', () => { - const { getByRole } = renderComponent(); + it('calls the correct handlers when clicking buttons for unit page', () => { + const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); const viewLiveButton = getByRole('button', { name: messages.viewLiveButton.defaultMessage }); fireEvent.click(viewLiveButton); @@ -38,5 +42,22 @@ describe('', () => { const previewButton = getByRole('button', { name: messages.previewButton.defaultMessage }); fireEvent.click(previewButton); expect(handlePreviewFn).toHaveBeenCalledTimes(1); + + const editButton = queryByRole('button', { name: messages.editButton.defaultMessage }); + expect(editButton).not.toBeInTheDocument(); + }); + + it('calls the correct handlers when clicking buttons for library page', () => { + const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.libraryContent.id }); + + const editButton = getByRole('button', { name: messages.editButton.defaultMessage }); + fireEvent.click(editButton); + expect(handleViewLiveFn).toHaveBeenCalledTimes(1); + + const viewLiveButton = queryByRole('button', { name: messages.viewLiveButton.defaultMessage }); + expect(viewLiveButton).not.toBeInTheDocument(); + + const previewButton = queryByRole('button', { name: messages.previewButton.defaultMessage }); + expect(previewButton).not.toBeInTheDocument(); }); }); diff --git a/src/course-unit/header-navigations/messages.js b/src/course-unit/header-navigations/messages.ts similarity index 59% rename from src/course-unit/header-navigations/messages.js rename to src/course-unit/header-navigations/messages.ts index 55e60fc965..53239434ac 100644 --- a/src/course-unit/header-navigations/messages.js +++ b/src/course-unit/header-navigations/messages.ts @@ -4,10 +4,17 @@ const messages = defineMessages({ viewLiveButton: { id: 'course-authoring.course-unit.button.view-live', defaultMessage: 'View live version', + description: 'The unit view live button text', }, previewButton: { id: 'course-authoring.course-unit.button.preview', defaultMessage: 'Preview', + description: 'The unit preview button text', + }, + editButton: { + id: 'course-authoring.course-unit.button.edit', + defaultMessage: 'Edit', + description: 'The unit edit button text', }, }); diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index 336d986fab..2219b467e4 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -9,6 +9,7 @@ import { } from '@openedx/paragon/icons'; import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import { getCourseUnitData } from '../data/selectors'; import { updateQueryPendingStatus } from '../data/slice'; import messages from './messages'; @@ -86,6 +87,9 @@ const HeaderTitle = ({ onConfigureSubmit={onConfigureSubmit} currentItemData={currentItemData} isSelfPaced={false} + isXBlockComponent={ + [COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.component.id].includes(currentItemData.category) + } /> {getVisibilityMessage()} diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx index 7e57c408e0..6fe98c5582 100644 --- a/src/course-unit/header-title/HeaderTitle.test.jsx +++ b/src/course-unit/header-title/HeaderTitle.test.jsx @@ -108,7 +108,7 @@ describe('', () => { ...courseUnitIndexMock, user_partition_info: { ...courseUnitIndexMock.user_partition_info, - selected_partition_index: '1', + selected_partition_index: 1, selected_groups_label: 'Visibility group 1', }, }); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 11731cc2ad..de55705c1c 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,42 +1,44 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; import { RequestStatus } from '../data/constants'; import { useCopyToClipboard } from '../generic/clipboard'; +import { useEventListener } from '../generic/hooks'; +import { COURSE_BLOCK_NAMES } from '../constants'; +import { messageTypes, PUBLISH_TYPES } from './constants'; import { createNewCourseXBlock, - fetchCourseUnitQuery, - editCourseItemQuery, - fetchCourseSectionVerticalData, - fetchCourseVerticalChildrenData, deleteUnitItemQuery, duplicateUnitItemQuery, + editCourseItemQuery, editCourseUnitVisibilityAndData, + fetchCourseSectionVerticalData, + fetchCourseUnitQuery, + fetchCourseVerticalChildrenData, getCourseOutlineInfoQuery, patchUnitItemQuery, } from './data/thunk'; import { + getCanEdit, + getCourseOutlineInfo, getCourseSectionVertical, - getCourseVerticalChildren, getCourseUnitData, + getCourseVerticalChildren, + getErrorMessage, getIsLoading, + getMovedXBlockParams, getSavingStatus, - getErrorMessage, getSequenceStatus, getStaticFileNotices, - getCanEdit, - getCourseOutlineInfo, - getMovedXBlockParams, } from './data/selectors'; import { changeEditTitleFormOpen, - updateQueryPendingStatus, updateMovedXBlockParams, + updateQueryPendingStatus, } from './data/slice'; import { useIframe } from './context/hooks'; -import { messageTypes, PUBLISH_TYPES } from './constants'; export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -49,7 +51,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const isLoading = useSelector(getIsLoading); const errorMessage = useSelector(getErrorMessage); const sequenceStatus = useSelector(getSequenceStatus); - const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical); + const { draftPreviewLink, publishedPreviewLink, xblockInfo = {} } = useSelector(getCourseSectionVertical); const courseVerticalChildren = useSelector(getCourseVerticalChildren); const staticFileNotices = useSelector(getStaticFileNotices); const navigate = useNavigate(); @@ -60,9 +62,10 @@ export const useCourseUnit = ({ courseId, blockId }) => { const { currentlyVisibleToStudents } = courseUnit; const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); const { canPasteComponent } = courseVerticalChildren; - - const unitTitle = courseUnit.metadata?.displayName || ''; + const { displayName: unitTitle, category: unitCategory } = xblockInfo; const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id; + const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id; + const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id; const headerNavigationsActions = { handleViewLive: () => { @@ -71,6 +74,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handlePreview: () => { window.open(draftPreviewLink, '_blank'); }, + handleEdit: () => {}, }; const handleTitleEdit = () => { @@ -86,7 +90,9 @@ export const useCourseUnit = ({ courseId, blockId }) => { isDiscussionEnabled, blockId, )); - closeModalFn(); + if (typeof closeModalFn === 'function') { + closeModalFn(); + } }; const handleTitleEditSubmit = (displayName) => { @@ -150,6 +156,17 @@ export const useCourseUnit = ({ courseId, blockId }) => { navigate(`/course/${courseId}/container/${movedXBlockParams.targetParentLocator}`); }; + const receiveMessage = useCallback(({ data }) => { + const { payload, type } = data; + + if (type === messageTypes.handleViewXBlockContent) { + const { usageId } = payload; + navigate(`/course/${courseId}/container/${usageId}/${sequenceId}`); + } + }, [courseId, sequenceId]); + + useEventListener('message', receiveMessage); + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -175,6 +192,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { sequenceId, courseUnit, unitTitle, + unitCategory, errorMessage, sequenceStatus, savingStatus, @@ -182,6 +200,8 @@ export const useCourseUnit = ({ courseId, blockId }) => { currentlyVisibleToStudents, isLoading, isTitleEditFormOpen, + isUnitVerticalType, + isUnitLibraryType, sharedClipboardData, showPasteXBlock, showPasteUnit, @@ -202,3 +222,35 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleNavigateToTargetUnit, }; }; + +/** + * Custom hook to determine the layout grid configuration based on unit category and type. + * + * @param {string} unitCategory - The category of the unit. This may influence future layout logic. + * @param {boolean} isUnitLibraryType - A flag indicating whether the unit is of library content type. + * @returns {Object} - An object representing the layout configuration for different screen sizes. + * The configuration includes keys like 'lg', 'md', 'sm', 'xs', and 'xl', + * each specifying an array of layout spans. + */ +export const useLayoutGrid = (unitCategory, isUnitLibraryType) => ( + useMemo(() => { + const layouts = { + fullWidth: { + lg: [{ span: 12 }, { span: 0 }], + md: [{ span: 12 }, { span: 0 }], + sm: [{ span: 12 }, { span: 0 }], + xs: [{ span: 12 }, { span: 0 }], + xl: [{ span: 12 }, { span: 0 }], + }, + default: { + lg: [{ span: 8 }, { span: 4 }], + md: [{ span: 8 }, { span: 4 }], + sm: [{ span: 8 }, { span: 3 }], + xs: [{ span: 9 }, { span: 3 }], + xl: [{ span: 9 }, { span: 3 }], + }, + }; + + return isUnitLibraryType ? layouts.fullWidth : layouts.default; + }, [unitCategory]) +); diff --git a/src/course-unit/move-modal/index.tsx b/src/course-unit/move-modal/index.tsx index 7844d7c310..220e1320f1 100644 --- a/src/course-unit/move-modal/index.tsx +++ b/src/course-unit/move-modal/index.tsx @@ -102,6 +102,7 @@ const MoveModal: FC = ({ onClose={handleCLoseModal} size="xl" className="move-xblock-modal" + title={intl.formatMessage(messages.moveModalTitle, { displayName })} hasCloseButton isFullscreenOnMobile > diff --git a/src/course-unit/move-modal/moveModal.test.tsx b/src/course-unit/move-modal/moveModal.test.tsx index ba94e018a9..6080a8c42e 100644 --- a/src/course-unit/move-modal/moveModal.test.tsx +++ b/src/course-unit/move-modal/moveModal.test.tsx @@ -4,8 +4,8 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import userEvent from '@testing-library/user-event'; +import userEvent from '@testing-library/user-event'; import initializeStore from '../../store'; import { getCourseOutlineInfoUrl } from '../data/api'; import { courseOutlineInfoMock } from '../__mocks__'; @@ -79,7 +79,9 @@ describe('', () => { const categoryIndicator: HTMLElement = getByTestId('move-xblock-modal-category'); expect(getByText(messages.moveModalTitle.defaultMessage.replace(' {displayName}', ''))).toBeInTheDocument(); - expect(within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage)).toBeInTheDocument(); + expect( + within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage), + ).toBeInTheDocument(); expect( within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSections.defaultMessage), ).toBeInTheDocument(); @@ -95,7 +97,9 @@ describe('', () => { const breadcrumbs: HTMLElement = getByTestId('move-xblock-modal-breadcrumbs'); const categoryIndicator: HTMLElement = getByTestId('move-xblock-modal-category'); - expect(within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage)).toBeInTheDocument(); + expect( + within(breadcrumbs).getByText(messages.moveModalBreadcrumbsBaseCategory.defaultMessage), + ).toBeInTheDocument(); expect( within(categoryIndicator).getByText(messages.moveModalBreadcrumbsSections.defaultMessage), ).toBeInTheDocument(); diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index dc39755183..87acd22659 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -108,6 +108,7 @@ const PreviewLibraryXBlockChanges = () => { isOpen={isModalOpen} onClose={closeModal} size="xl" + title={getTitle()} className="lib-preview-xblock-changes-modal" hasCloseButton isFullscreenOnMobile diff --git a/src/course-unit/sidebar/components/sidebar-footer/index.jsx b/src/course-unit/sidebar/components/sidebar-footer/index.jsx index ee1e816bad..62af6c672b 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/index.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/index.jsx @@ -43,9 +43,9 @@ const SidebarFooter = ({ SidebarFooter.propTypes = { locationId: PropTypes.string, displayUnitLocation: PropTypes.bool, - openDiscardModal: PropTypes.func.isRequired, - openVisibleModal: PropTypes.func.isRequired, - handlePublishing: PropTypes.func.isRequired, + openDiscardModal: PropTypes.func, + openVisibleModal: PropTypes.func, + handlePublishing: PropTypes.func, visibleToStaffOnly: PropTypes.bool.isRequired, }; diff --git a/src/course-unit/utils.test.ts b/src/course-unit/utils.test.ts new file mode 100644 index 0000000000..ab45700c74 --- /dev/null +++ b/src/course-unit/utils.test.ts @@ -0,0 +1,25 @@ +import { adoptCourseSectionUrl } from './utils'; + +describe('adoptCourseSectionUrl', () => { + it('should transform container URL correctly', () => { + const params = { + courseId: 'some-course-id', + parentUnitId: 'some-sequence-id', + unitId: 'some-unit-id', + url: '/container/some-unit-id', + }; + const result = adoptCourseSectionUrl(params); + expect(result).toBe(`/course/${params.courseId}/container/${params.unitId}/${params.parentUnitId}`); + }); + + it('should return original URL if no transformation is applied', () => { + const params = { + courseId: 'some-course-id', + parentUnitId: 'some-sequence-id', + unitId: 'some-unit-id', + url: '/some/other/url', + }; + const result = adoptCourseSectionUrl(params); + expect(result).toBe('/some/other/url'); + }); +}); diff --git a/src/course-unit/utils.ts b/src/course-unit/utils.ts new file mode 100644 index 0000000000..08c009994e --- /dev/null +++ b/src/course-unit/utils.ts @@ -0,0 +1,30 @@ +/** + * Adapts API URL paths to the application's internal URL format based on predefined conditions. + * + * @param {Object} params - Parameters for URL adaptation. + * @param {string} params.url - The original API URL to transform. + * @param {string} params.courseId - The course ID. + * @param {string} params.parentUnitId - The sequence ID. + * @returns {string} - A correctly formatted internal route for the application. + */ +export const adoptCourseSectionUrl = ( + { url, courseId, parentUnitId }: { url: string, courseId: string, parentUnitId: string }, +): string => { + let newUrl = url; + const urlConditions = [ + { + regex: /^\/container\/(.+)/, + transform: (unitId: string) => `/course/${courseId}/container/${unitId}/${parentUnitId}`, + }, + ]; + + for (const { regex, transform } of urlConditions) { + const match = regex.exec(url); + if (match?.[1]) { + newUrl = transform(match[1]); + break; + } + } + + return newUrl; +}; diff --git a/src/generic/configure-modal/ConfigureModal.jsx b/src/generic/configure-modal/ConfigureModal.jsx index 04c82200df..073b362048 100644 --- a/src/generic/configure-modal/ConfigureModal.jsx +++ b/src/generic/configure-modal/ConfigureModal.jsx @@ -166,6 +166,7 @@ const ConfigureModal = ({ ); break; case COURSE_BLOCK_NAMES.vertical.id: + case COURSE_BLOCK_NAMES.libraryContent.id: case COURSE_BLOCK_NAMES.component.id: // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 if (data.selectedPartitionIndex >= 0) { @@ -242,10 +243,12 @@ const ConfigureModal = ({ ); case COURSE_BLOCK_NAMES.vertical.id: + case COURSE_BLOCK_NAMES.libraryContent.id: case COURSE_BLOCK_NAMES.component.id: return ( 0 && ( -

+

+ +


@@ -146,10 +149,12 @@ const UnitTab = ({ UnitTab.defaultProps = { isXBlockComponent: false, + isLibraryContent: false, }; UnitTab.propTypes = { isXBlockComponent: PropTypes.bool, + isLibraryContent: PropTypes.bool, values: PropTypes.shape({ isVisibleToStaffOnly: PropTypes.bool.isRequired, discussionEnabled: PropTypes.bool.isRequired, @@ -157,9 +162,7 @@ UnitTab.propTypes = { PropTypes.string, PropTypes.number, ]).isRequired, - selectedGroups: PropTypes.oneOfType([ - PropTypes.string, - ]), + selectedGroups: PropTypes.arrayOf(PropTypes.string), }).isRequired, setFieldValue: PropTypes.func.isRequired, showWarning: PropTypes.bool.isRequired, diff --git a/src/generic/configure-modal/messages.js b/src/generic/configure-modal/messages.js index 41ef703bd8..ccc6ca2d0e 100644 --- a/src/generic/configure-modal/messages.js +++ b/src/generic/configure-modal/messages.js @@ -46,6 +46,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-access', defaultMessage: 'Unit access', }, + libraryContentAccess: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.lib-content-access', + defaultMessage: 'Library content access', + }, discussionEnabledSectionTitle: { id: 'course-authoring.course-outline.configure-modal.discussion-enabled.section-title', defaultMessage: 'Discussion', diff --git a/src/setupTest.js b/src/setupTest.js index 4cc847c713..776da0c0b0 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -39,6 +39,10 @@ mergeConfig({ LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null, + ACCOUNT_PROFILE_URL: process.env.ACCOUNT_PROFILE_URL || null, + ACCOUNT_SETTINGS_URL: process.env.ACCOUNT_SETTINGS_URL || null, + IGNORED_ERROR_REGEX: process.env.IGNORED_ERROR_REGEX || null, + MFE_CONFIG_API_URL: process.env.MFE_CONFIG_API_URL || null, ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false', ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',