feat: [FC-0070] rendering library content in unit page (#1475)

The enables opening a Library Content page within the new Studio unit page. This page displays the xBlocks from the specified library and provides basic configuration options for the library.
This commit is contained in:
Ihor Romaniuk
2025-01-16 18:06:48 +01:00
committed by GitHub
parent 98fbcff842
commit 619ab9a267
33 changed files with 482 additions and 187 deletions

View File

@@ -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' },
});

View File

@@ -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={(
<Breadcrumbs />
<Breadcrumbs
courseId={courseId}
parentUnitId={sequenceId}
/>
)}
headerActions={(
<HeaderNavigations
unitCategory={unitCategory}
headerNavigationsActions={headerNavigationsActions}
/>
)}
/>
<Sequence
courseId={courseId}
sequenceId={sequenceId}
unitId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}
/>
<Layout
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 }]}
>
{isUnitVerticalType && (
<Sequence
courseId={courseId}
sequenceId={sequenceId}
unitId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}
/>
)}
<Layout {...layoutGrid}>
<Layout.Element>
{currentlyVisibleToStudents && (
<AlertMessage
@@ -186,11 +190,13 @@ const CourseUnit = ({ courseId }) => {
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
<AddComponent
blockId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
/>
{showPasteXBlock && canPasteComponent && (
{isUnitVerticalType && (
<AddComponent
blockId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
/>
)}
{showPasteXBlock && canPasteComponent && isUnitVerticalType && (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={handleCreateNewCourseXBlock}
@@ -207,18 +213,21 @@ const CourseUnit = ({ courseId }) => {
</Layout.Element>
<Layout.Element>
<Stack gap={3}>
<Sidebar data-testid="course-unit-sidebar">
<PublishControls blockId={blockId} />
</Sidebar>
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
&& (
<Sidebar className="tags-sidebar">
<TagsSidebarControls />
</Sidebar>
{isUnitVerticalType && (
<>
<Sidebar data-testid="course-unit-sidebar">
<PublishControls blockId={blockId} />
</Sidebar>
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Sidebar className="tags-sidebar">
<TagsSidebarControls />
</Sidebar>
)}
<Sidebar data-testid="course-unit-location-sidebar">
<LocationInfo />
</Sidebar>
</>
)}
<Sidebar data-testid="course-unit-location-sidebar">
<LocationInfo />
</Sidebar>
</Stack>
</Layout.Element>
</Layout>

View File

@@ -6,6 +6,10 @@
@import "./move-modal";
@import "./preview-changes";
.course-unit {
min-width: 900px;
}
.course-unit__alert {
margin-bottom: 1.75rem;
}

View File

@@ -54,6 +54,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';
@@ -164,6 +165,9 @@ describe('<CourseUnit />', () => {
global.localStorage.clear();
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, courseUnitIndexMock);
@@ -505,6 +509,19 @@ describe('<CourseUnit />', () => {
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');
@@ -1264,9 +1281,7 @@ describe('<CourseUnit />', () => {
.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);
@@ -1540,7 +1555,7 @@ describe('<CourseUnit />', () => {
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, {});
.reply(200, courseUnitIndexMock);
await act(async () => {
await waitFor(() => {
@@ -1817,4 +1832,61 @@ describe('<CourseUnit />', () => {
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
});
});
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(<RootWrapper />);
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(<RootWrapper />);
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();
});
});
});
});

View File

@@ -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([]);

View File

@@ -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;

View File

@@ -72,7 +72,7 @@ const ComponentModalView = ({
<OverlayTrigger
placement="right"
overlay={(
<Tooltip>
<Tooltip id={`${componentTemplate.displayName}-support-tooltip`}>
{supportLabels[componentTemplate.supportLevel].tooltip}
</Tooltip>
)}

View File

@@ -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 (
<nav className="d-flex align-center mb-2.5">
<ol className="p-0 m-0 d-flex align-center">
<li className="d-flex">
<Dropdown>
<Dropdown.Toggle id="breadcrumbs-dropdown-section" variant="link" className="p-0 text-primary small">
<span className="small text-gray-700">{section.title}</span>
<Icon
src={ArrowDropDownIcon}
className="text-primary ml-1"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{section.children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCourseOutlinePage(url)}
className="small"
data-testid="breadcrumbs-section-dropdown-item"
>
{displayName}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
<Icon
src={ChevronRightIcon}
size="md"
className="text-primary mx-2"
alt={intl.formatMessage(messages.altIconChevron)}
/>
</li>
<li className="d-flex">
<Dropdown>
<Dropdown.Toggle id="breadcrumbs-dropdown-subsection" variant="link" className="p-0 text-primary">
<span className="small text-gray-700">{subsection.title}</span>
<Icon
src={ArrowDropDownIcon}
className="text-primary ml-1"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{subsection.children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCourseOutlinePage(url)}
className="small"
data-testid="breadcrumbs-subsection-dropdown-item"
>
{displayName}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</li>
</ol>
</nav>
);
};
export default Breadcrumbs;

View File

@@ -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(
<Breadcrumbs courseId={courseId} />,
<Breadcrumbs courseId={courseId} parentUnitId={parentUnitId} />,
);
describe('<Breadcrumbs />', () => {
@@ -69,6 +70,39 @@ describe('<Breadcrumbs />', () => {
});
});
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('<Breadcrumbs />', () => {
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('<Breadcrumbs />', () => {
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}`);
});
});

View File

@@ -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 (
<nav className="d-flex align-center mb-2.5">
<ol className="p-0 m-0 d-flex align-center">
{ancestorXblocks.map(({ children, title, isLast }, index) => (
<li
className="d-flex"
// eslint-disable-next-line react/no-array-index-key
key={`${title}-${index}`}
>
<Dropdown>
<Dropdown.Toggle
id="breadcrumbs-dropdown-section"
variant="link"
className="p-0 text-primary small"
>
<span className="small text-gray-700">
{title}
</span>
<Icon
src={ArrowDropDownIcon}
className="text-primary ml-1"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCoursePage(index < 2, url)}
className="small"
data-testid={`breadcrumbs-dropdown-item-level-${index}`}
>
{displayName}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
{!isLast && (
<Icon
src={ChevronRightIcon}
size="md"
className="text-primary mx-2"
/>
)}
</li>
))}
</ol>
</nav>
);
};
export default Breadcrumbs;

View File

@@ -5,7 +5,7 @@ import { FILE_LIST_DEFAULT_VALUE } from '../constants';
const FileList = ({ fileList }) => (
<ul>
{fileList.map((fileName) => (
<li>{fileName}</li>
<li key={fileName}>{fileName}</li>
))}
</ul>
);

View File

@@ -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),

View File

@@ -62,4 +62,5 @@ export const messageTypes = {
refreshXBlockPositions: 'refreshPositions',
newXBlockEditor: 'newXBlockEditor',
toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown',
handleViewXBlockContent: 'handleViewXBlockContent',
};

View File

@@ -1,4 +1,4 @@
import {
import React, {
createContext, MutableRefObject, useRef, useCallback, useMemo, ReactNode,
} from 'react';
import { logError } from '@edx/frontend-platform/logging';

View File

@@ -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<any>} A promise that resolves with the response data.
*/
export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess, isDiscussionEnabled) {

View File

@@ -68,11 +68,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');
@@ -101,11 +101,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));

View File

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

View File

@@ -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 (
<nav className="header-navigations ml-auto flex-shrink-0">
<Button
variant="outline-primary"
onClick={handleViewLive}
>
{intl.formatMessage(messages.viewLiveButton)}
</Button>
<Button
variant="outline-primary"
onClick={handlePreview}
>
{intl.formatMessage(messages.previewButton)}
</Button>
{unitCategory === COURSE_BLOCK_NAMES.vertical.id && (
<>
<Button
variant="outline-primary"
onClick={handleViewLive}
>
{intl.formatMessage(messages.viewLiveButton)}
</Button>
<Button
variant="outline-primary"
onClick={handlePreview}
>
{intl.formatMessage(messages.previewButton)}
</Button>
</>
)}
{unitCategory === COURSE_BLOCK_NAMES.libraryContent.id && (
<Button
iconBefore={EditIcon}
variant="outline-primary"
onClick={handleEdit}
>
{intl.formatMessage(messages.editButton)}
</Button>
)}
</nav>
);
};
@@ -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;

View File

@@ -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('<HeaderNavigations />', () => {
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('<HeaderNavigations />', () => {
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();
});
});

View File

@@ -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',
},
});

View File

@@ -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 { messageTypes } from '../constants';
@@ -94,6 +95,9 @@ const HeaderTitle = ({
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
isSelfPaced={false}
isXBlockComponent={
[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.component.id].includes(currentItemData.category)
}
/>
</div>
{getVisibilityMessage()}

View File

@@ -116,7 +116,7 @@ describe('<HeaderTitle />', () => {
...courseUnitIndexMock,
user_partition_info: {
...courseUnitIndexMock.user_partition_info,
selected_partition_index: '1',
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1',
},
});

View File

@@ -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 {
getCourseSectionVertical,
getCourseVerticalChildren,
getCourseUnitData,
getIsLoading,
getSavingStatus,
getErrorMessage,
getSequenceStatus,
getStaticFileNotices,
getCanEdit,
getCourseOutlineInfo,
getCourseSectionVertical,
getCourseUnitData,
getCourseVerticalChildren,
getErrorMessage,
getIsLoading,
getMovedXBlockParams,
getSavingStatus,
getSequenceStatus,
getStaticFileNotices,
} 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])
);

View File

@@ -102,6 +102,7 @@ const MoveModal: FC<IUseMoveModalParams> = ({
onClose={handleCLoseModal}
size="xl"
className="move-xblock-modal"
title={intl.formatMessage(messages.moveModalTitle, { displayName })}
hasCloseButton
isFullscreenOnMobile
>

View File

@@ -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('<MoveModal />', () => {
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('<MoveModal />', () => {
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();

View File

@@ -108,6 +108,7 @@ const PreviewLibraryXBlockChanges = () => {
isOpen={isModalOpen}
onClose={closeModal}
size="xl"
title={getTitle()}
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile

View File

@@ -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,
};

View File

@@ -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');
});
});

30
src/course-unit/utils.ts Normal file
View File

@@ -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;
};

View File

@@ -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 = ({
</Tabs>
);
case COURSE_BLOCK_NAMES.vertical.id:
case COURSE_BLOCK_NAMES.libraryContent.id:
case COURSE_BLOCK_NAMES.component.id:
return (
<UnitTab
isXBlockComponent={COURSE_BLOCK_NAMES.component.id === category}
isXBlockComponent={isXBlockComponent}
isLibraryContent={COURSE_BLOCK_NAMES.libraryContent.id === category}
values={values}
setFieldValue={setFieldValue}
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY && !ancestorHasStaffLock}
@@ -260,6 +263,7 @@ const ConfigureModal = ({
return (
<ModalDialog
className="configure-modal"
title={dialogTitle}
size="lg"
isOpen={isOpen}
onClose={onClose}

View File

@@ -11,6 +11,7 @@ import messages from './messages';
const UnitTab = ({
isXBlockComponent,
isLibraryContent,
values,
setFieldValue,
showWarning,
@@ -61,7 +62,9 @@ const UnitTab = ({
)}
{userPartitionInfo.selectablePartitions.length > 0 && (
<Form.Group controlId="groupSelect">
<h4 className="mt-3"><FormattedMessage {...messages.unitAccess} /></h4>
<h4 className="mt-3">
<FormattedMessage {...messages[isLibraryContent ? 'libraryContentAccess' : 'unitAccess']} />
</h4>
<hr />
<Form.Label as="legend" className="font-weight-bold">
<FormattedMessage {...messages.restrictAccessTo} />
@@ -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,

View File

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

View File

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