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:
@@ -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' },
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
@import "./move-modal";
|
||||
@import "./preview-changes";
|
||||
|
||||
.course-unit {
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
.course-unit__alert {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -72,7 +72,7 @@ const ComponentModalView = ({
|
||||
<OverlayTrigger
|
||||
placement="right"
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<Tooltip id={`${componentTemplate.displayName}-support-tooltip`}>
|
||||
{supportLabels[componentTemplate.supportLevel].tooltip}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
80
src/course-unit/breadcrumbs/Breadcrumbs.tsx
Normal file
80
src/course-unit/breadcrumbs/Breadcrumbs.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -62,4 +62,5 @@ export const messageTypes = {
|
||||
refreshXBlockPositions: 'refreshPositions',
|
||||
newXBlockEditor: 'newXBlockEditor',
|
||||
toggleCourseXBlockDropdown: 'toggleCourseXBlockDropdown',
|
||||
handleViewXBlockContent: 'handleViewXBlockContent',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import React, {
|
||||
createContext, MutableRefObject, useRef, useCallback, useMemo, ReactNode,
|
||||
} from 'react';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
|
||||
@@ -102,6 +102,7 @@ const MoveModal: FC<IUseMoveModalParams> = ({
|
||||
onClose={handleCLoseModal}
|
||||
size="xl"
|
||||
className="move-xblock-modal"
|
||||
title={intl.formatMessage(messages.moveModalTitle, { displayName })}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -108,6 +108,7 @@ const PreviewLibraryXBlockChanges = () => {
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
size="xl"
|
||||
title={getTitle()}
|
||||
className="lib-preview-xblock-changes-modal"
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
25
src/course-unit/utils.test.ts
Normal file
25
src/course-unit/utils.test.ts
Normal 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
30
src/course-unit/utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user