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',