Compare commits

...

9 Commits

Author SHA1 Message Date
Asad Ali
14e122a672 fix: do not reload multiple tabs on block save (backport #2600) (#2704) 2025-12-01 18:26:06 -05:00
Asad Ali
f459f53343 fix: load sequences in unit page (#1867) (#2424)
This handles loading errors when opening the course unit page via direct link as an unauthorized user.

Co-authored-by: Ihor Romaniuk <ihor.romaniuk@raccoongang.com>
2025-09-08 22:23:06 -07:00
Asad Ali
a5a7d03d12 fix: allow thumbnail upload on Videos page if no thumbnail (#2388) (#2434)
* fix: allow thumbnail upload if no thumbnail

* fix: improve thumbnail upload impl

* test: fix tests

* test: fix tests

* fix: do not show thumbnail upload if not allowed

* test: fix coverage

* test: add thumbnail test

* fix: display thumbnail overlay when video status is success
2025-09-08 20:45:27 -07:00
Chris Chávez
41fc478efe [Teak] style: Fixing nits about sync units [FC-0097] (#2320)
* Add a warning banner about units in the libraries sync page.
* Update the message in the sync unit modal.
* Stay visible the sync icon in the course outline.
* Add a tooltip to the edit (in normal and disabled mode) and sync button.
2025-08-28 11:15:24 -05:00
Muhammad Faraz Maqsood
06497bf85c fix: publish btn doesn't show after component edit
When we edit & save the component, publish button doesn't show up until we refresh the page manualy or open this unit by opening previous unit and coming back to this unit again.
In this commit, we are dispatching a storage event whenever we edit the component, it'll refresh the page & show the publish button as expected.
2025-08-20 15:08:24 +05:00
Jacobo Dominguez
7e0b7f94e8 docs: (backport) adding comprehensive readme documentation for plugin slots (#2340) 2025-07-29 15:27:24 -07:00
Jansen Kantor
4bc34c268b fix: pages and resources plugins not rendered (#1885) 2025-07-22 13:26:38 +05:30
Muhammad Anas
2973614e3b fix: loading unit page directly from link after logging in in Teak (#2246)
This is a simple version of the fix for Teak; on master it was fixed with https://github.com/openedx/frontend-app-authoring/pull/1867
2025-07-09 09:35:58 -07:00
Brayan Cerón
bdc99fddc3 fix: clear selection on files & uploads page after deleting (backport) (#2228)
* refactor: remove selected rows when deleting or adding elements

* refactor: ensure unique asset IDs when adding new ones

* refactor: remove unnecessary loading checks in mockStore function

* test: add unit tests for TableActions component
2025-07-07 16:47:45 -07:00
44 changed files with 785 additions and 1507 deletions

View File

@@ -82,7 +82,7 @@ describe('<CourseLibraries />', () => {
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
const alert = await screen.findByRole('alert');
const alert = (await screen.findAllByRole('alert'))[0];
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
@@ -105,7 +105,7 @@ describe('<CourseLibraries />', () => {
userEvent.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
const alert = await screen.findByRole('alert');
const alert = (await screen.findAllByRole('alert'))[0];
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
@@ -133,7 +133,7 @@ describe('<CourseLibraries />', () => {
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
const alert = await screen.findByRole('alert');
const alert = (await screen.findAllByRole('alert'))[0];
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
@@ -156,7 +156,7 @@ describe('<CourseLibraries />', () => {
screen.logTestingPlaygroundURL();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryAllByRole('alert').length).toEqual(1);
});
});

View File

@@ -17,7 +17,7 @@ import {
Tabs,
} from '@openedx/paragon';
import {
Cached, CheckCircle, Launch, Loop,
Cached, CheckCircle, Launch, Loop, Info,
} from '@openedx/paragon/icons';
import sumBy from 'lodash/sumBy';
@@ -33,6 +33,7 @@ import { useStudioHome } from '../studio-home/hooks';
import NewsstandIcon from '../generic/NewsstandIcon';
import ReviewTabContent from './ReviewTabContent';
import { OutOfSyncAlert } from './OutOfSyncAlert';
import AlertMessage from '../generic/alert-message';
interface Props {
courseId: string;
@@ -199,6 +200,12 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
showAlert={showReviewAlert && tabKey === CourseLibraryTabs.all}
setShowAlert={setShowReviewAlert}
/>
{ /* TODO: Remove this alert after implement container in this page */}
<AlertMessage
title={intl.formatMessage(messages.unitsUpdatesWarning)}
icon={Info}
variant="info"
/>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}

View File

@@ -116,6 +116,11 @@ const messages = defineMessages({
defaultMessage: 'Something went wrong! Could not fetch results.',
description: 'Generic error message displayed when fetching link data fails.',
},
unitsUpdatesWarning: {
id: 'course-authoring.course-libraries.home-tab.warning.units',
defaultMessage: 'Currently this page only tracks component updates. To check for unit updates, go to your Course Outline.',
description: 'Warning message shown in library sync page about units updates.',
},
});
export default messages;

View File

@@ -1,6 +1,7 @@
// @ts-check
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSearchParams } from 'react-router-dom';
@@ -10,6 +11,7 @@ import {
Hyperlink,
Icon,
IconButton,
IconButtonWithTooltip,
useToggle,
} from '@openedx/paragon';
import {
@@ -133,19 +135,24 @@ const CardHeader = ({
) : (
<>
{titleComponent}
{readyToSync && (
<IconButton
className="item-card-button-icon"
data-testid={`${namePrefix}-sync-button`}
alt={intl.formatMessage(messages.readyToSyncButtonAlt)}
iconAs={SyncIcon}
onClick={onClickSync}
/>
)}
<IconButton
className="item-card-button-icon"
<IconButtonWithTooltip
className={classNames(
'item-card-button-icon',
{
'item-card-button-icon-disabled': isDisabledEditField,
},
)}
data-testid={`${namePrefix}-edit-button`}
alt={intl.formatMessage(messages.altButtonEdit)}
alt={intl.formatMessage(
isDisabledEditField ? messages.cannotEditTooltip : messages.altButtonRename,
)}
tooltipContent={(
<div>
{intl.formatMessage(
isDisabledEditField ? messages.cannotEditTooltip : messages.altButtonRename,
)}
</div>
)}
iconAs={EditIcon}
onClick={onClickEdit}
// @ts-ignore
@@ -161,6 +168,15 @@ const CardHeader = ({
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
)}
{extraActionsComponent}
{readyToSync && (
<IconButtonWithTooltip
data-testid={`${namePrefix}-sync-button`}
alt={intl.formatMessage(messages.readyToSyncButtonAlt)}
iconAs={SyncIcon}
tooltipContent={<div>{intl.formatMessage(messages.readyToSyncButtonAlt)}</div>}
onClick={onClickSync}
/>
)}
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
<Dropdown.Toggle
className="item-card-header__menu"

View File

@@ -25,6 +25,12 @@
&:hover {
.item-card-button-icon {
opacity: 1;
&.item-card-button-icon-disabled {
pointer-events: all;
opacity: .5;
cursor: default;
}
}
}
}

View File

@@ -29,9 +29,9 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.card.status-badge.draft-unpublished-changes',
defaultMessage: 'Draft (Unpublished changes)',
},
altButtonEdit: {
altButtonRename: {
id: 'course-authoring.course-outline.card.button.edit.alt',
defaultMessage: 'Edit',
defaultMessage: 'Rename',
},
menuPublish: {
id: 'course-authoring.course-outline.card.menu.publish',
@@ -82,6 +82,11 @@ const messages = defineMessages({
defaultMessage: 'Update available - click to sync',
description: 'Alt text for the sync icon button.',
},
cannotEditTooltip: {
id: 'course-authoring.course-outline.card.button.edit.disable.tooltip',
defaultMessage: 'This object was added from a library, so it cannot be edited.',
description: 'Tooltip text of button when the object was added from a library.',
},
});
export default messages;

View File

@@ -176,7 +176,7 @@ describe('<UnitCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available')).toBeInTheDocument();
expect(screen.getByText('Preview not available for unit changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -196,7 +196,7 @@ describe('<UnitCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available')).toBeInTheDocument();
expect(screen.getByText('Preview not available for unit changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });

View File

@@ -41,6 +41,7 @@ const CourseUnit = ({ courseId }) => {
courseUnit,
isLoading,
sequenceId,
courseUnitLoadingStatus,
unitTitle,
unitCategory,
errorMessage,
@@ -210,6 +211,7 @@ const CourseUnit = ({ courseId }) => {
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
courseUnitLoadingStatus={courseUnitLoadingStatus}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}

View File

@@ -17,7 +17,6 @@ import { cloneDeep, set } from 'lodash';
import {
getCourseSectionVerticalApiUrl,
getCourseUnitApiUrl,
getCourseVerticalChildrenApiUrl,
getCourseOutlineInfoUrl,
getXBlockBaseApiUrl,
@@ -28,7 +27,6 @@ import {
deleteUnitItemQuery,
editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
fetchCourseUnitQuery,
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
@@ -37,13 +35,12 @@ import initializeStore from '../store';
import {
courseCreateXblockMock,
courseSectionVerticalMock,
courseUnitIndexMock,
courseUnitMock,
courseVerticalChildrenMock,
clipboardMockResponse,
courseOutlineInfoMock,
} from './__mocks__';
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
import { clipboardUnit } from '../__mocks__';
import { executeThunk } from '../utils';
import { IFRAME_FEATURE_POLICY } from '../constants';
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
@@ -72,7 +69,8 @@ let store;
let queryClient;
const courseId = '123';
const blockId = '567890';
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5';
const unitDisplayName = courseSectionVerticalMock.xblock_info.display_name;
const mockedUsedNavigate = jest.fn();
const userName = 'openedx';
const handleConfigureSubmitMock = jest.fn();
@@ -90,7 +88,7 @@ const postXBlockBody = {
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ blockId }),
useParams: () => ({ blockId, sequenceId }),
useNavigate: () => mockedUsedNavigate,
}));
@@ -146,14 +144,10 @@ describe('<CourseUnit />', () => {
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, courseUnitIndexMock);
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId, courseId), store.dispatch);
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);
@@ -168,8 +162,8 @@ describe('<CourseUnit />', () => {
it('render CourseUnit component correctly', async () => {
render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
await waitFor(() => {
const unitHeaderTitle = screen.getByTestId('unit-header-title');
@@ -278,11 +272,14 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_changes: true,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
has_changes: true,
published_by: userName,
},
});
await waitFor(() => {
@@ -314,11 +311,14 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_changes: true,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
has_changes: true,
published_by: userName,
},
});
await waitFor(() => {
@@ -381,12 +381,15 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -395,7 +398,7 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
@@ -405,6 +408,9 @@ describe('<CourseUnit />', () => {
axiosMock
.onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id))
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, courseSectionVerticalMock);
await executeThunk(deleteUnitItemQuery(
courseId,
courseVerticalChildrenMock.children[0].block_id,
@@ -425,8 +431,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
@@ -445,15 +451,15 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
});
@@ -465,10 +471,6 @@ describe('<CourseUnit />', () => {
id: courseVerticalChildrenMock.children[0].block_id,
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
@@ -518,12 +520,15 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -532,7 +537,7 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
@@ -540,8 +545,8 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
@@ -561,15 +566,15 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
});
@@ -612,12 +617,15 @@ describe('<CourseUnit />', () => {
}))
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
metadata: {
...courseUnitIndexMock.metadata,
display_name: newDisplayName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
});
axiosMock
@@ -690,12 +698,15 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -710,8 +721,8 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -724,15 +735,15 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
@@ -794,12 +805,15 @@ describe('<CourseUnit />', () => {
},
}))
.reply(200, { dummy: 'value' })
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
metadata: {
...courseUnitIndexMock.metadata,
display_name: newDisplayName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
})
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -844,12 +858,15 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -861,7 +878,7 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
@@ -874,8 +891,8 @@ describe('<CourseUnit />', () => {
userEvent.click(videoButton);
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -888,15 +905,15 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
waffleSpy.mockRestore();
@@ -918,12 +935,15 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -933,7 +953,7 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
@@ -953,8 +973,8 @@ describe('<CourseUnit />', () => {
*/
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -967,15 +987,15 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
@@ -991,22 +1011,22 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
});
it('renders course unit details in the sidebar', async () => {
render(<RootWrapper />);
const courseUnitLocationId = extractCourseUnitId(courseUnitIndexMock.id);
const courseUnitLocationId = extractCourseUnitId(courseSectionVerticalMock.xblock_info.id);
await waitFor(() => {
expect(screen.getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument();
@@ -1033,13 +1053,16 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
currently_visible_to_students: false,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
currently_visible_to_students: false,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
await waitFor(() => {
const alert = screen.queryAllByRole('alert').find(
@@ -1076,11 +1099,14 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true), store.dispatch);
@@ -1113,8 +1139,8 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null), store.dispatch);
@@ -1143,12 +1169,15 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
@@ -1157,7 +1186,7 @@ describe('<CourseUnit />', () => {
.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(within(courseUnitSidebar).getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(publishBtn).not.toBeInTheDocument();
@@ -1199,9 +1228,14 @@ describe('<CourseUnit />', () => {
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock, published: true, has_changes: false,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
published: true,
has_changes: false,
},
});
await executeThunk(editCourseUnitVisibilityAndData(
@@ -1258,17 +1292,20 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onPost(getXBlockBaseApiUrl(courseUnitIndexMock.id), {
.onPost(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id), {
publish: null,
metadata: { visible_to_staff_only: true, group_access: { 50: [2] }, discussion_enabled: true },
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.replyOnce(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
},
});
const modalSaveBtn = within(configureModal)
@@ -1307,13 +1344,15 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
@@ -1362,20 +1401,17 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(screen.getByRole('button', { name: messages.pasteButtonText.defaultMessage }));
await waitFor(() => {
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
@@ -1427,13 +1463,15 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
@@ -1477,13 +1515,15 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
@@ -1529,13 +1569,15 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
enable_copy_paste_units: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
@@ -1687,8 +1729,8 @@ describe('<CourseUnit />', () => {
.reply(200, {});
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await screen.findByText(unitDisplayName);
@@ -1970,7 +2012,6 @@ describe('<CourseUnit />', () => {
describe('Library Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
@@ -1987,20 +2028,6 @@ describe('<CourseUnit />', () => {
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
category: 'library_content',
ancestor_info: {
...courseUnitIndexMock.ancestor_info,
child_info: {
...courseUnitIndexMock.ancestor_info.child_info,
category: 'library_content',
},
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
it('navigates to library content page on receive window event', async () => {
@@ -2020,8 +2047,8 @@ describe('<CourseUnit />', () => {
findByTestId,
} = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const unitHeaderTitle = await findByTestId('unit-header-title');
await findByText(unitDisplayName);
@@ -2049,7 +2076,6 @@ describe('<CourseUnit />', () => {
describe('Split Test Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
@@ -2066,20 +2092,6 @@ describe('<CourseUnit />', () => {
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
category: 'split_test',
ancestor_info: {
...courseUnitIndexMock.ancestor_info,
child_info: {
...courseUnitIndexMock.ancestor_info.child_info,
category: 'split_test',
},
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
it('navigates to split test content page on receive window event', async () => {
@@ -2124,8 +2136,8 @@ describe('<CourseUnit />', () => {
it('should render split test content page correctly', async () => {
render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components';
waitFor(() => {
@@ -2210,10 +2222,6 @@ describe('<CourseUnit />', () => {
? { ...child, block_type: 'html' }
: child));
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
@@ -2251,15 +2259,19 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
upstreamInfo: {
upstreamRef: 'lct:org:lib:unit:unit-1',
upstreamLink: 'some-link',
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
upstreamInfo: {
...courseSectionVerticalMock.xblock_info,
upstreamRef: 'lct:org:lib:unit:unit-1',
upstreamLink: 'some-link',
},
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();

View File

@@ -1,1126 +0,0 @@
module.exports = {
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
display_name: 'Getting Started',
category: 'vertical',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'needs_attention',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: true,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
data: '',
metadata: {
display_name: 'Getting Started',
xml_attributes: {
filename: [
'vertical/867dddb6f55d410caaa9c1eb9c6743ec.xml',
'vertical/867dddb6f55d410caaa9c1eb9c6743ec.xml',
],
},
},
ancestor_info: {
ancestors: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
display_name: 'Lesson 1 - Getting Started',
category: 'sequential',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%4019a30717eff543078a5d94ae9d6c18a5',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'needs_attention',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
hide_after_due: false,
is_proctored_exam: false,
was_exam_ever_linked_with_external: false,
online_proctoring_rules: '',
is_practice_exam: false,
is_onboarding_exam: false,
is_time_limited: false,
exam_review_rules: '',
default_time_limit_minutes: null,
proctoring_exam_configuration_link: null,
supports_onboarding: false,
show_review_rules: true,
child_info: {
category: 'vertical',
display_name: 'Unit',
children: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
display_name: 'Getting Started',
category: 'vertical',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'needs_attention',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: true,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
display_name: 'Working with Videos',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
display_name: 'Videos on edX',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
display_name: 'Video Demonstrations',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
display_name: 'Video Presentation Styles',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
display_name: 'Interactive Questions',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606',
display_name: 'Exciting Labs and Tools',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
display_name: 'Reading Assignments',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193',
display_name: 'When Are Your Exams? ',
category: 'vertical',
has_children: true,
edited_on: 'Dec 28, 2023 at 10:00 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
discussion_enabled: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
},
],
},
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions',
display_name: 'Example Week 1: Getting Started',
category: 'chapter',
has_children: true,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Dec 28, 2023 at 10:00 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40interactive_demonstrations',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
highlights: [],
highlights_enabled: true,
highlights_preview_only: false,
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
display_name: 'Demonstration Course',
category: 'course',
has_children: true,
unit_level_discussions: false,
edited_on: 'Jan 03, 2024 at 12:06 UTC',
published: true,
published_on: 'Jan 03, 2024 at 08:57 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: null,
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
highlights_enabled_for_messaging: false,
highlights_enabled: true,
highlights_preview_only: false,
highlights_doc_url: 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_course_highlight_emails.html',
enable_proctored_exams: false,
create_zendesk_tickets: true,
enable_timed_exams: true,
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
],
},
ancestor_has_staff_lock: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
enable_copy_paste_units: false,
edited_by: 'edx',
published_by: null,
currently_visible_to_students: true,
has_partition_group_components: false,
release_date_from: 'Section "Example Week 1: Getting Started"',
staff_lock_from: null,
upstreamInfo: {
upstreamLink: undefined,
},
};

View File

@@ -1,4 +1,3 @@
export { default as courseUnitIndexMock } from './courseUnitIndex';
export { default as courseSectionVerticalMock } from './courseSectionVertical';
export { default as courseUnitMock } from './courseUnit';
export { default as courseCreateXblockMock } from './courseCreateXblock';

View File

@@ -5,11 +5,11 @@ import {
} from '../../testUtils';
import { executeThunk } from '../../utils';
import { getCourseSectionVerticalApiUrl, getCourseUnitApiUrl } from '../data/api';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import { fetchCourseSectionVerticalData, fetchCourseUnitQuery } from '../data/thunk';
import { courseSectionVerticalMock, courseUnitIndexMock } from '../__mocks__';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { courseSectionVerticalMock } from '../__mocks__';
import Breadcrumbs from './Breadcrumbs';
let axiosMock;
@@ -43,9 +43,9 @@ describe('<Breadcrumbs />', () => {
reduxStore = mocks.reduxStore;
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, courseUnitIndexMock);
await executeThunk(fetchCourseUnitQuery(courseId), reduxStore.dispatch);
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(courseId), reduxStore.dispatch);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, courseSectionVerticalMock);

View File

@@ -12,16 +12,19 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
const isLastUnit = !nextUrl;
const sequenceIds = useSelector(getSequenceIds);
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
let unitIndex = sequence?.unitIds.indexOf(currentUnitId);
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
if (!unitIndex) {
// Handle case where unitIndex is not found
unitIndex = 0;
}
let nextLink;
const nextIndex = unitIndex + 1;
if (nextIndex < sequence.unitIds.length) {
const nextUnitId = sequence.unitIds[nextIndex];
if (nextIndex < sequence?.unitIds.length) {
const nextUnitId = sequence?.unitIds[nextIndex];
nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`;
} else if (nextSequenceId) {
const pathToNextUnit = decodeURIComponent(nextUrl);
@@ -32,7 +35,7 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
const previousIndex = unitIndex - 1;
if (previousIndex >= 0) {
const previousUnitId = sequence.unitIds[previousIndex];
const previousUnitId = sequence?.unitIds[previousIndex];
previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`;
} else if (previousSequenceId) {
const pathToPreviousUnit = decodeURIComponent(prevUrl);

View File

@@ -35,7 +35,7 @@ const SequenceNavigation = ({
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
const renderUnitButtons = () => {
if (sequence.unitIds?.length === 0 || unitId === null) {
if (sequence.unitIds.length === 0 || unitId === null) {
return (
<div style={{ flexBasis: '100%', minWidth: 0, borderBottom: 'solid 1px #EAEAEA' }} />
);
@@ -43,7 +43,7 @@ const SequenceNavigation = ({
return (
<SequenceNavigationTabs
unitIds={sequence.unitIds || []}
unitIds={sequence?.unitIds || []}
unitId={unitId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}

View File

@@ -7,7 +7,6 @@ import { isUnitReadOnly, normalizeCourseSectionVerticalData, updateXBlockBlockId
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`;
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
@@ -15,20 +14,6 @@ export const getCourseOutlineInfoUrl = (courseId) => `${getStudioBaseUrl()}/cour
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
export const libraryBlockChangesUrl = (blockId) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
/**
* Get course unit.
* @param {string} unitId
* @returns {Promise<Object>}
*/
export async function getCourseUnitData(unitId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseUnitApiUrl(unitId));
const result = camelCaseObject(data);
result.readOnly = isUnitReadOnly(result);
return result;
}
/**
* Edit course unit display name.
* @param {string} unitId
@@ -47,15 +32,18 @@ export async function editUnitDisplayName(unitId, displayName) {
}
/**
* Get an object containing course section vertical data.
* Fetch vertical block data from the container_handler endpoint.
* @param {string} unitId
* @returns {Promise<Object>}
*/
export async function getCourseSectionVerticalData(unitId) {
export async function getVerticalData(unitId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionVerticalApiUrl(unitId));
return normalizeCourseSectionVerticalData(data);
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
courseSectionVerticalData.xblockInfo.readOnly = isUnitReadOnly(courseSectionVerticalData.xblockInfo);
return courseSectionVerticalData;
}
/**

View File

@@ -1,7 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { RequestStatus } from 'CourseAuthoring/data/constants';
export const getCourseUnitData = (state) => state.courseUnit.unit;
export const getCourseUnitData = (state) => state.courseUnit.courseSectionVertical.xblockInfo ?? {};
export const getCanEdit = (state) => state.courseUnit.canEdit;
export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices;
export const getCourseUnit = (state) => state.courseUnit;
@@ -16,7 +16,7 @@ export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerti
export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo;
export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus;
export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams;
const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
export const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
export const getIsLoading = createSelector(
[getLoadingStatuses],
loadingStatus => Object.values(loadingStatus)

View File

@@ -12,11 +12,9 @@ const slice = createSlice({
isTitleEditFormOpen: false,
canEdit: true,
loadingStatus: {
fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS,
courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS,
courseVerticalChildrenLoadingStatus: RequestStatus.IN_PROGRESS,
},
unit: {},
courseSectionVertical: {},
courseVerticalChildren: { children: [], isPublished: true },
staticFileNotices: {},
@@ -31,15 +29,6 @@ const slice = createSlice({
},
},
reducers: {
fetchCourseItemSuccess: (state, { payload }) => {
state.unit = payload;
},
updateLoadingCourseUnitStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
fetchUnitLoadingStatus: payload.status,
};
},
updateQueryPendingStatus: (state, { payload }) => {
state.isQueryPending = payload;
},
@@ -81,12 +70,6 @@ const slice = createSlice({
createUnitXblockLoadingStatus: payload.status,
};
},
addNewUnitStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
fetchUnitLoadingStatus: payload.status,
};
},
updateCourseVerticalChildren: (state, { payload }) => {
state.courseVerticalChildren = payload;
},
@@ -109,8 +92,6 @@ const slice = createSlice({
});
export const {
fetchCourseItemSuccess,
updateLoadingCourseUnitStatus,
updateSavingStatus,
updateModel,
fetchSequenceRequest,

View File

@@ -10,9 +10,8 @@ import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '../../generic/model-store';
import { messageTypes } from '../constants';
import {
getCourseUnitData,
editUnitDisplayName,
getCourseSectionVerticalData,
getVerticalData,
createCourseXblock,
getCourseVerticalChildren,
handleCourseUnitVisibilityAndData,
@@ -22,8 +21,6 @@ import {
patchUnitItem,
} from './api';
import {
updateLoadingCourseUnitStatus,
fetchCourseItemSuccess,
updateSavingStatus,
fetchSequenceRequest,
fetchSequenceFailure,
@@ -40,30 +37,13 @@ import {
} from './slice';
import { getNotificationMessage } from './utils';
export function fetchCourseUnitQuery(courseId) {
return async (dispatch) => {
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const courseUnit = await getCourseUnitData(courseId);
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchCourseSectionVerticalData(courseId, sequenceId) {
return async (dispatch) => {
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(fetchSequenceRequest({ sequenceId }));
try {
const courseSectionVerticalData = await getCourseSectionVerticalData(courseId);
const courseSectionVerticalData = await getVerticalData(courseId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateModel({
@@ -94,8 +74,7 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) {
try {
await editUnitDisplayName(itemId, displayName).then(async (result) => {
if (result) {
const courseUnit = await getCourseUnitData(itemId);
const courseSectionVerticalData = await getCourseSectionVerticalData(itemId);
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateModel({
@@ -107,7 +86,6 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) {
models: courseSectionVerticalData.units || [],
}));
dispatch(fetchSequenceSuccess({ sequenceId }));
dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
@@ -146,8 +124,8 @@ export function editCourseUnitVisibilityAndData(
if (callback) {
callback();
}
const courseUnit = await getCourseUnitData(blockId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(blockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
@@ -174,7 +152,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
if (result) {
const formattedResult = camelCaseObject(result);
if (body.category === 'vertical') {
const courseSectionVerticalData = await getCourseSectionVerticalData(formattedResult.locator);
const courseSectionVerticalData = await getVerticalData(formattedResult.locator);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
}
if (body.stagedContent) {
@@ -194,8 +172,8 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
sendMessageToIframe(messageTypes.addXBlock, { data: result });
}
const currentBlockId = body.category === 'vertical' ? formattedResult.locator : blockId;
const courseUnit = await getCourseUnitData(currentBlockId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(currentBlockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
}
});
} catch (error) {
@@ -240,8 +218,8 @@ export function deleteUnitItemQuery(itemId, xblockId, sendMessageToIframe) {
try {
await deleteUnitItem(xblockId);
sendMessageToIframe(messageTypes.completeXBlockDeleting, { locator: xblockId });
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -259,8 +237,8 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
try {
const { courseKey, locator } = await duplicateUnitItem(itemId, xblockId);
callback(courseKey, locator);
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
@@ -316,8 +294,8 @@ export function patchUnitItemQuery({
dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
callbackFn(sourceLocator);
try {
const courseUnit = await getCourseUnitData(currentParentLocator);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(currentParentLocator);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
} catch (error) {
handleResponseErrors(error, dispatch, updateSavingStatus);
}
@@ -335,8 +313,8 @@ export function updateCourseUnitSidebar(itemId) {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit));
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {

View File

@@ -8,9 +8,9 @@ import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { getCourseUnitApiUrl } from '../data/api';
import { fetchCourseUnitQuery } from '../data/thunk';
import { courseUnitIndexMock } from '../__mocks__';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { courseSectionVerticalMock } from '../__mocks__';
import HeaderTitle from './HeaderTitle';
import messages from './messages';
@@ -52,9 +52,9 @@ describe('<HeaderTitle />', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
});
it('render HeaderTitle component correctly', () => {
@@ -80,14 +80,18 @@ describe('<HeaderTitle />', () => {
// Override mock unit with one sourced from an upstream library
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
upstreamInfo: {
upstreamRef: 'lct:org:lib:unit:unit-1',
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
upstreamInfo: {
...courseSectionVerticalMock.xblock_info.upstreamInfo,
upstreamRef: 'lct:org:lib:unit:unit-1',
},
},
});
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByRole } = renderComponent();
@@ -122,16 +126,19 @@ describe('<HeaderTitle />', () => {
it('displays a visibility message with the selected groups for the unit', async () => {
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
user_partition_info: {
...courseUnitIndexMock.user_partition_info,
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1',
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
...courseSectionVerticalMock.xblock_info.user_partition_info,
selected_partition_index: 1,
selected_groups_label: 'Visibility group 1',
},
},
});
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByText } = renderComponent();
const visibilityMessage = messages.definedVisibilityMessage.defaultMessage
.replace('{selectedGroupsLabel}', 'Visibility group 1');
@@ -143,12 +150,15 @@ describe('<HeaderTitle />', () => {
it('displays a visibility message with the selected groups for some of xblock', async () => {
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
has_partition_group_components: true,
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
has_partition_group_components: true,
},
});
await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const { getByText } = renderComponent();
await waitFor(() => {

View File

@@ -18,10 +18,10 @@ import {
editCourseItemQuery,
editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
fetchCourseUnitQuery,
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
updateCourseUnitSidebar,
} from './data/thunk';
import {
getCanEdit,
@@ -35,6 +35,7 @@ import {
getSavingStatus,
getSequenceStatus,
getStaticFileNotices,
getLoadingStatuses,
} from './data/selectors';
import {
changeEditTitleFormOpen,
@@ -51,6 +52,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
const courseUnit = useSelector(getCourseUnitData);
const courseUnitLoadingStatus = useSelector(getLoadingStatuses);
const savingStatus = useSelector(getSavingStatus);
const isLoading = useSelector(getIsLoading);
const errorMessage = useSelector(getErrorMessage);
@@ -196,7 +198,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
}, [savingStatus]);
useEffect(() => {
dispatch(fetchCourseUnitQuery(blockId));
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
handleNavigate(sequenceId);
@@ -215,9 +216,27 @@ export const useCourseUnit = ({ courseId, blockId }) => {
}
}, [isMoveModalOpen]);
useEffect(() => {
const handlePageRefreshUsingStorage = (event) => {
// ignoring tests for if block, because it triggers when someone
// edits the component using editor which has a separate store
/* istanbul ignore next */
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
dispatch(updateCourseUnitSidebar(blockId));
localStorage.removeItem(event.key);
}
};
window.addEventListener('storage', handlePageRefreshUsingStorage);
return () => {
window.removeEventListener('storage', handlePageRefreshUsingStorage);
};
}, [blockId, sequenceId, isSplitTestType]);
return {
sequenceId,
courseUnit,
courseUnitLoadingStatus,
unitTitle,
unitCategory,
errorMessage,

View File

@@ -10,10 +10,10 @@ import userEvent from '@testing-library/user-event';
import initializeStore from '../../../../store';
import { executeThunk } from '../../../../utils';
import { clipboardUnit } from '../../../../__mocks__';
import { getCourseUnitApiUrl } from '../../../data/api';
import { getCourseSectionVerticalApiUrl } from '../../../data/api';
import { getClipboardUrl } from '../../../../generic/data/api';
import { fetchCourseUnitQuery } from '../../../data/thunk';
import { courseUnitIndexMock } from '../../../__mocks__';
import { fetchCourseSectionVerticalData } from '../../../data/thunk';
import { courseSectionVerticalMock } from '../../../__mocks__';
import messages from '../../messages';
import ActionButtons from './ActionButtons';
@@ -46,8 +46,14 @@ describe('<ActionButtons />', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, { ...courseUnitIndexMock, enable_copy_paste_units: true });
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
enable_copy_paste_units: true,
},
});
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
@@ -57,7 +63,7 @@ describe('<ActionButtons />', () => {
queryClient = new QueryClient();
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
});
it('render ActionButtons component with Copy to clipboard', () => {
@@ -74,7 +80,9 @@ describe('<ActionButtons />', () => {
userEvent.click(copyXBlockBtn);
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ usage_key: courseUnitIndexMock.id }));
expect(axiosMock.history.post[0].data).toBe(
JSON.stringify({ usage_key: courseSectionVerticalMock.xblock_info.id }),
);
jest.resetAllMocks();
});
});

View File

@@ -99,4 +99,4 @@ export const getIconVariant = (visibilityState, published, hasChanges) => {
* @param {string} id - The course unit ID.
* @returns {string} The clear course unit ID extracted from the provided data.
*/
export const extractCourseUnitId = (id) => id.match(/block@(.+)$/)[1];
export const extractCourseUnitId = (id) => id?.match(/block@(.+)$/)[1];

View File

@@ -37,9 +37,16 @@ import { useIframeContent } from '../../generic/hooks/useIframeContent';
import { useIframeMessages } from '../../generic/hooks/useIframeMessages';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
import { RequestStatus } from '../../data/constants';
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
courseId,
blockId,
unitXBlockActions,
courseVerticalChildren,
handleConfigureSubmit,
isUnitVerticalType,
courseUnitLoadingStatus,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -70,6 +77,23 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
setIframeRef(iframeRef);
}, [setIframeRef]);
useEffect(() => {
const iframe = iframeRef?.current;
if (!iframe) { return undefined; }
const handleIframeLoad = () => {
if (courseUnitLoadingStatus.fetchUnitLoadingStatus === RequestStatus.FAILED) {
window.location.reload();
}
};
iframe.addEventListener('load', handleIframeLoad);
return () => {
iframe.removeEventListener('load', handleIframeLoad);
};
}, [iframeRef]);
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
closeXBlockEditorModal();
closeVideoSelectorModal();

View File

@@ -42,6 +42,11 @@ export interface XBlockContainerIframeProps {
courseId: string;
blockId: string;
isUnitVerticalType: boolean,
courseUnitLoadingStatus: {
fetchUnitLoadingStatus: string;
fetchVerticalChildrenLoadingStatus: string;
fetchXBlockDataLoadingStatus: string;
};
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => void;
handleDuplicate: (XBlockId: string | null) => void;

View File

@@ -125,6 +125,16 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => {
content,
onSuccess: (response) => {
dispatch(actions.app.setSaveResponse(response));
const parsedData = JSON.parse(response.config.data);
if (parsedData?.has_changes || !('has_changes' in parsedData)) {
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
sessionStorage.setItem(storageKey, Date.now());
window.dispatchEvent(new StorageEvent('storage', {
key: storageKey,
newValue: Date.now().toString(),
}));
}
returnToUnit(response.data);
},
}));

View File

@@ -352,7 +352,11 @@ describe('app thunkActions', () => {
});
it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => {
dispatch.mockClear();
const response = 'testRESPONSE';
const mockParsedData = { has_changes: true };
const response = {
config: { data: JSON.stringify(mockParsedData) },
data: {},
};
calls[1][0].saveBlock.onSuccess(response);
expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response));
expect(returnToUnit).toHaveBeenCalled();

View File

@@ -70,15 +70,6 @@ const mockStore = async (
}
renderComponent();
await executeThunk(fetchAssets(courseId), store.dispatch);
// Finish loading the expected files into the data table before returning,
// because loading new files can disrupt things like accessing file menus.
if (status === RequestStatus.SUCCESSFUL) {
const numFiles = skipNextPageFetch ? 13 : 15;
await waitFor(() => {
expect(screen.getByText(`Showing ${numFiles} of ${numFiles}`)).toBeInTheDocument();
});
}
};
const emptyMockStore = async (status) => {

View File

@@ -28,7 +28,7 @@ const slice = createSlice({
if (isEmpty(state.assetIds)) {
state.assetIds = payload.assetIds;
} else {
state.assetIds = [...state.assetIds, ...payload.assetIds];
state.assetIds = [...new Set([...state.assetIds, ...payload.assetIds])];
}
},
setSortedAssetIds: (state, { payload }) => {

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
@@ -7,6 +7,7 @@ import {
AlertModal,
Button,
Collapsible,
DataTableContext,
Hyperlink,
Truncate,
} from '@openedx/paragon';
@@ -22,6 +23,13 @@ const DeleteConfirmationModal = ({
// injected
intl,
}) => {
const { clearSelection } = useContext(DataTableContext);
const handleConfirmDeletion = () => {
handleBulkDelete();
clearSelection();
};
const firstSelectedRow = selectedRows[0]?.original;
let activeContentRows = [];
if (Array.isArray(selectedRows)) {
@@ -73,7 +81,7 @@ const DeleteConfirmationModal = ({
<Button variant="tertiary" onClick={closeDeleteConfirmation}>
{intl.formatMessage(messages.cancelButtonLabel)}
</Button>
<Button onClick={handleBulkDelete}>
<Button onClick={handleConfirmDeletion}>
{intl.formatMessage(messages.deleteFileButtonLabel)}
</Button>
</ActionRow>

View File

@@ -273,6 +273,16 @@ const FileTable = ({
setSelectedRows={setSelectedRows}
fileType={fileType}
/>
<DeleteConfirmationModal
{...{
isDeleteConfirmationOpen,
closeDeleteConfirmation,
handleBulkDelete,
selectedRows,
fileType,
}}
/>
</DataTable>
<FileInput key="generic-file-upload" fileInput={fileInputControl} supportedFileFormats={supportedFileFormats} />
{!isEmpty(selectedRows) && (
@@ -286,15 +296,7 @@ const FileTable = ({
sidebar={infoModalSidebar}
/>
)}
<DeleteConfirmationModal
{...{
isDeleteConfirmationOpen,
closeDeleteConfirmation,
handleBulkDelete,
selectedRows,
fileType,
}}
/>
</div>
);
};

View File

@@ -26,13 +26,18 @@ const TableActions = ({
intl,
}) => {
const [isSortOpen, openSort, closeSort] = useToggle(false);
const { state } = useContext(DataTableContext);
const { state, clearSelection } = useContext(DataTableContext);
// This useEffect saves DataTable state so it can persist after table re-renders due to data reload.
useEffect(() => {
setInitialState(state);
}, [state]);
const handleOpenFileSelector = () => {
fileInputControl.click();
clearSelection();
};
return (
<>
<Button variant="outline-primary" onClick={openSort} iconBefore={Tune}>
@@ -71,7 +76,7 @@ const TableActions = ({
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Button iconBefore={Add} onClick={fileInputControl.click}>
<Button iconBefore={Add} onClick={handleOpenFileSelector}>
{intl.formatMessage(messages.addFilesButtonLabel, { fileType })}
</Button>
<SortAndFilterModal {...{ isSortOpen, closeSort, handleSort }} />

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
import { DataTableContext } from '@openedx/paragon';
import { initializeMocks, render } from '../../../testUtils';
import TableActions from './TableActions';
import messages from '../messages';
const defaultProps = {
selectedFlatRows: [],
fileInputControl: { click: jest.fn() },
handleOpenDeleteConfirmation: jest.fn(),
handleBulkDownload: jest.fn(),
encodingsDownloadUrl: null,
handleSort: jest.fn(),
fileType: 'video',
setInitialState: jest.fn(),
intl: {
formatMessage: (msg, values) => msg.defaultMessage.replace('{fileType}', values?.fileType ?? ''),
},
};
const mockColumns = [
{
id: 'wrapperType',
Header: 'Type',
accessor: 'wrapperType',
filter: 'includes',
},
];
const renderWithContext = (props = {}, contextOverrides = {}) => {
const contextValue = {
state: {
selectedRowIds: {},
filters: [],
...contextOverrides.state,
},
clearSelection: jest.fn(),
gotoPage: jest.fn(),
setAllFilters: jest.fn(),
columns: mockColumns,
...contextOverrides,
};
return render(
<DataTableContext.Provider value={contextValue}>
<TableActions {...defaultProps} {...props} />
</DataTableContext.Provider>,
);
};
describe('TableActions', () => {
beforeEach(() => {
initializeMocks();
jest.clearAllMocks();
});
test('renders buttons and dropdown', () => {
renderWithContext();
expect(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })).toBeInTheDocument();
});
test('disables bulk and delete actions if no rows selected', () => {
renderWithContext();
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
const downloadOption = screen.getByText(messages.downloadTitle.defaultMessage);
const deleteButton = screen.getByTestId('open-delete-confirmation-button');
expect(downloadOption).toHaveAttribute('aria-disabled', 'true');
expect(downloadOption).toHaveClass('disabled');
expect(deleteButton).toHaveAttribute('aria-disabled', 'true');
expect(deleteButton).toHaveClass('disabled');
});
test('enables bulk and delete actions when rows are selected', () => {
renderWithContext({
selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
});
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
expect(screen.getByText(messages.downloadTitle.defaultMessage)).not.toBeDisabled();
expect(screen.getByTestId('open-delete-confirmation-button')).not.toBeDisabled();
});
test('calls file input click and clears selection when add button clicked', () => {
const mockClick = jest.fn();
const mockClear = jest.fn();
renderWithContext({ fileInputControl: { click: mockClick } }, {}, mockClear);
fireEvent.click(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') }));
expect(mockClick).toHaveBeenCalled();
});
test('opens sort modal when sort button clicked', () => {
renderWithContext();
fireEvent.click(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
test('calls handleBulkDownload when selected and clicked', () => {
const handleBulkDownload = jest.fn();
renderWithContext({
selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
handleBulkDownload,
});
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
fireEvent.click(screen.getByText(messages.downloadTitle.defaultMessage));
expect(handleBulkDownload).toHaveBeenCalled();
});
test('calls handleOpenDeleteConfirmation when clicked', () => {
const handleOpenDeleteConfirmation = jest.fn();
const selectedFlatRows = [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }];
renderWithContext({
selectedFlatRows,
handleOpenDeleteConfirmation,
});
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
expect(handleOpenDeleteConfirmation).toHaveBeenCalledWith(selectedFlatRows);
});
test('shows encoding download link when provided', () => {
const encodingsDownloadUrl = '/some/path/to/encoding.zip';
renderWithContext({ encodingsDownloadUrl });
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
expect(screen.getByRole('link', { name: messages.downloadEncodingsTitle.defaultMessage })).toHaveAttribute('href', expect.stringContaining(encodingsDownloadUrl));
});
});

View File

@@ -48,21 +48,30 @@ const VideoThumbnail = ({
const isFailed = VIDEO_FAILURE_STATUSES.includes(status);
const failedMessage = intl.formatMessage(messages.failedCheckboxLabel);
const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded;
const showThumbnail = allowThumbnailUpload && isUploaded;
return (
<div className="video-thumbnail row justify-content-center align-itmes-center">
{allowThumbnailUpload && showThumbnail && <div className="thumbnail-overlay" />}
{allowThumbnailUpload && isUploaded && <div className="thumbnail-overlay" />}
{showThumbnail && !thumbnailError && pageLoadStatus === RequestStatus.SUCCESSFUL ? (
<>
<div className="border rounded">
<Image
style={imageSize}
className="m-1 bg-light-300"
src={thumbnail}
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
onError={() => setThumbnailError(true)}
/>
{ thumbnail ? (
<Image
style={imageSize}
className="m-1 bg-light-300"
src={thumbnail}
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
onError={() => setThumbnailError(true)}
/>
) : (
<div
className="row justify-content-center align-items-center m-0"
style={imageSize}
>
<Icon src={VideoFile} style={{ height: '48px', width: '48px' }} />
</div>
)}
</div>
<div className="add-thumbnail" data-testid={`video-thumbnail-${id}`}>
<Button

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import VideoThumbnail from './VideoThumbnail';
import { VIDEO_SUCCESS_STATUSES } from './data/constants';
import { RequestStatus } from '../../data/constants';
it('shows fallback icon if thumbnail fails to load', () => {
const { container } = render(
<IntlProvider locale="en">
<VideoThumbnail
thumbnail="http://bad-url/image.png"
displayName="Test Video"
id="vid1"
imageSize={{ width: '100px', height: '100px' }}
handleAddThumbnail={jest.fn()}
videoImageSettings={{ videoImageUploadEnabled: true, supportedFileFormats: { jpg: 'image/jpg' } }}
status={VIDEO_SUCCESS_STATUSES[0]}
pageLoadStatus={RequestStatus.SUCCESSFUL}
/>
</IntlProvider>,
);
const image = screen.getByRole('img', { name: /video thumbnail/i });
expect(image).toBeInTheDocument();
fireEvent.error(image);
expect(screen.queryByRole('img', { name: /video thumbnail/i })).toBeNull();
const fallbackSvg = container.querySelector('svg[role="img"]');
expect(fallbackSvg).toBeInTheDocument();
});

View File

@@ -19,7 +19,7 @@ const messages = defineMessages({
},
previewNotAvailable: {
id: 'course-authoring.library-authoring.component-comparison.preview-not-available',
defaultMessage: 'Preview not available',
defaultMessage: 'Preview not available for unit changes at this time',
description: 'Message shown when preview is not available.',
},
});

View File

@@ -92,7 +92,7 @@ const PagesAndResources = ({ courseId }) => {
<Route path=":appId/settings" element={<PageWrap><SettingsComponent url={redirectUrl} /></PageWrap>} />
</Routes>
<PageGrid pages={pages} pluginSlotComponent={AdditionalCoursePluginSlot} courseId={courseId} />
<PageGrid pages={pages} pluginSlotComponent={<AdditionalCoursePluginSlot />} courseId={courseId} />
{
(contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin)
&& (
@@ -100,7 +100,7 @@ const PagesAndResources = ({ courseId }) => {
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
<h3 className="m-0">{intl.formatMessage(messages.contentPermissions)}</h3>
</div>
<PageGrid pages={contentPermissionsPages} pluginSlotComponent={AdditionalCourseContentPluginSlot} />
<PageGrid pages={contentPermissionsPages} pluginSlotComponent={<AdditionalCourseContentPluginSlot />} />
</>
)
}

View File

@@ -1,16 +1,39 @@
import { screen, waitFor } from '@testing-library/react';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
import { PagesAndResources } from '.';
import { render } from './utils.test';
const mockPlugin = (identifier) => ({
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'mock-plugin-1',
type: DIRECT_PLUGIN,
priority: 1,
RenderWidget: () => <div data-testid={identifier}>HELLO</div>,
},
},
],
});
const courseId = 'course-v1:edX+TestX+Test_Course';
describe('PagesAndResources', () => {
beforeEach(() => {
jest.clearAllMocks();
setConfig({
...getConfig(),
pluginSlots: {
'org.openedx.frontend.authoring.additional_course_plugin.v1': mockPlugin('additional_course_plugin'),
'org.openedx.frontend.authoring.additional_course_content_plugin.v1': mockPlugin('additional_course_content_plugin'),
},
});
});
it('doesn\'t show content permissions section if relevant apps are not enabled', () => {
it('doesn\'t show content permissions section if relevant apps are not enabled', async () => {
const initialState = {
models: {
courseApps: {},
@@ -25,8 +48,11 @@ describe('PagesAndResources', () => {
{ preloadedState: initialState },
);
expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).not.toBeInTheDocument());
});
it('show content permissions section if Learning Assistant app is enabled', async () => {
const initialState = {
models: {
@@ -56,6 +82,8 @@ describe('PagesAndResources', () => {
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
});
it('show content permissions section if Xpert learning summaries app is enabled', async () => {
@@ -89,5 +117,7 @@ describe('PagesAndResources', () => {
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
});
});

View File

@@ -3,4 +3,4 @@
### Slot ID: `org.openedx.frontend.authoring.additional_course_content_plugin.v1`
### Slot ID Aliases
* `additional_course_content_plugin`
* `additional_course_content_plugin`

View File

@@ -1,5 +1,4 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
import React from 'react';
export const AdditionalCourseContentPluginSlot = () => (
<PluginSlot

View File

@@ -1,6 +1,64 @@
# AdditionalCoursePluginSlot
# Additional Course Plugin Slot
### Slot ID: `org.openedx.frontend.authoring.additional_course_plugin.v1`
### Slot ID Aliases
* `additional_course_plugin`
## Description
This slot is used to add a custom card on the the page & resources page.
## Example
The following `env.config.jsx` will add a custom card at the end of the page & resources section.
![Screenshot of the unit sidebar surrounded by border](./images/additional-course-plugin-slot-example.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { Badge, Card } from '@openedx/paragon';
import { Settings } from '@openedx/paragon/icons';
const config = {
pluginSlots: {
'org.openedx.frontend.authoring.additional_course_plugin.v1': {
plugins: [
{
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'default_contents',
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_additional_course',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<Card className={'shadow justify-content-between'} >
<Card.Header
title={'Additional Course'}
subtitle={(
<Badge variant="success" className="mt-1">
slot props course
</Badge>
)}
actions={<Settings />}
size="sm"
/>
<Card.Body>
<Card.Section>
Additional course from slot props description.
Or anything else.
</Card.Section>
</Card.Body>
</Card>
),
},
},
]
}
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -1,5 +1,4 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
import React from 'react';
export const AdditionalCoursePluginSlot = () => (
<PluginSlot

View File

@@ -13,3 +13,54 @@
* `additionalProps` - Object
* `transcriptType` - String
* `isAiTranslationsEnabled` - Boolean
## Description
This slot is used to add a custom block in the **Video Transcription Settings** drawer.
## Example
The following `env.config.jsx` will add a custom transcript option in the Transcript Settings drawer.
![Screenshot of the unit sidebar surrounded by border](./images/additional-translation-example.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { Collapsible, Icon } from '@openedx/paragon';
import { ChevronRight } from '@openedx/paragon/icons';
const TranslationsBlock = ({ setIsAiTranslations, courseId }) => (
<div key="transcript-type-selection" className="mt-3">
<Collapsible.Advanced
onOpen={() => setIsAiTranslations(courseId === 'anyId')}
>
<Collapsible.Trigger
className="row m-0 justify-content-between align-items-center"
>
Custom transcript 💬
<Icon src={ChevronRight} />
</Collapsible.Trigger>
</Collapsible.Advanced>
</div>
);
const config = {
pluginSlots: {
'org.openedx.frontend.authoring.video_transcript_additional_translations_component.v1': {
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_additional_translation_id',
type: DIRECT_PLUGIN,
RenderWidget: TranslationsBlock,
},
},
],
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB