feat: Unit creation button logic and refactoring
This commit is contained in:
committed by
Adolfo R. Brandes
parent
90fb3d8edc
commit
7fcc501d2e
@@ -73,6 +73,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
|
||||
@@ -27,8 +27,10 @@ const CourseUnit = ({ courseId }) => {
|
||||
isLoading,
|
||||
sequenceId,
|
||||
unitTitle,
|
||||
isQueryPending,
|
||||
savingStatus,
|
||||
isTitleEditFormOpen,
|
||||
isEditTitleFormOpen,
|
||||
isErrorAlert,
|
||||
isInternetConnectionAlertFailed,
|
||||
handleTitleEditSubmit,
|
||||
headerNavigationsActions,
|
||||
@@ -52,7 +54,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
<>
|
||||
<Container size="xl" className="course-unit px-4">
|
||||
<section className="course-unit-container mb-4 mt-5">
|
||||
<ErrorAlert hideHeading isError={savingStatus === RequestStatus.FAILED}>
|
||||
<ErrorAlert hideHeading isError={savingStatus === RequestStatus.FAILED && isErrorAlert}>
|
||||
{intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })}
|
||||
</ErrorAlert>
|
||||
<SubHeader
|
||||
@@ -60,7 +62,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
title={(
|
||||
<HeaderTitle
|
||||
unitTitle={unitTitle}
|
||||
isTitleEditFormOpen={isTitleEditFormOpen}
|
||||
isEditTitleFormOpen={isEditTitleFormOpen}
|
||||
handleTitleEdit={handleTitleEdit}
|
||||
handleTitleEditSubmit={handleTitleEditSubmit}
|
||||
/>
|
||||
@@ -78,6 +80,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={blockId}
|
||||
handleCreateNewCourseXblock={handleCreateNewCourseXblock}
|
||||
/>
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
@@ -101,11 +104,13 @@ const CourseUnit = ({ courseId }) => {
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
/>
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
isQueryPending={savingStatus === RequestStatus.PENDING}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
{isQueryPending && (
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
isQueryPending={savingStatus === RequestStatus.PENDING}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { cloneDeep, set } from 'lodash';
|
||||
|
||||
import {
|
||||
getCourseSectionVerticalApiUrl,
|
||||
@@ -23,11 +24,13 @@ import {
|
||||
courseCreateXblockMock,
|
||||
courseSectionVerticalMock,
|
||||
courseUnitIndexMock,
|
||||
courseUnitMock,
|
||||
} from './__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import CourseUnit from './CourseUnit';
|
||||
import headerNavigationsMessages from './header-navigations/messages';
|
||||
import headerTitleMessages from './header-title/messages';
|
||||
import courseSequenceMessages from './course-sequence/messages';
|
||||
import messages from './add-component/messages';
|
||||
|
||||
let axiosMock;
|
||||
@@ -192,6 +195,93 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
|
||||
const { getByRole, getAllByTestId } = render(<RootWrapper />);
|
||||
let units = null;
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
await waitFor(async () => {
|
||||
units = getAllByTestId('course-unit-btn');
|
||||
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
expect(units.length).toEqual(courseUnits.length);
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl(), { parent_locator: blockId, category: 'vertical', display_name: 'Unit' })
|
||||
.reply(200, { dummy: 'value' });
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...updatedCourseSectionVerticalData,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
const addNewUnitBtn = getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
|
||||
units = getAllByTestId('course-unit-btn');
|
||||
const updatedCourseUnits = updatedCourseSectionVerticalData
|
||||
.xblock_info.ancestor_info.ancestors[0].child_info.children;
|
||||
|
||||
userEvent.click(addNewUnitBtn);
|
||||
expect(units.length).toEqual(updatedCourseUnits.length);
|
||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
||||
expect(mockedUsedNavigate)
|
||||
.toHaveBeenCalledWith(`/course/${courseId}/container/${blockId}/${updatedAncestorsChild.id}`, { replace: true });
|
||||
});
|
||||
|
||||
it('the sequence unit is updated after changing the unit header', async () => {
|
||||
const { getAllByTestId, getByRole } = render(<RootWrapper />);
|
||||
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
|
||||
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
|
||||
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
|
||||
...updatedAncestorsChild.child_info.children,
|
||||
courseUnitMock,
|
||||
]);
|
||||
|
||||
const newDisplayName = `${unitDisplayName} new`;
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl(blockId, {
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
}))
|
||||
.reply(200, { dummy: 'value' })
|
||||
.onGet(getCourseUnitApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseUnitIndexMock,
|
||||
metadata: {
|
||||
...courseUnitIndexMock.metadata,
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
})
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...updatedCourseSectionVerticalData,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
const editTitleButton = getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
|
||||
fireEvent.click(editTitleButton);
|
||||
|
||||
const titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
|
||||
fireEvent.change(titleEditField, { target: { value: newDisplayName } });
|
||||
|
||||
await act(async () => fireEvent.blur(titleEditField));
|
||||
|
||||
await waitFor(async () => {
|
||||
const units = getAllByTestId('course-unit-btn');
|
||||
expect(units.some(unit => unit.title === newDisplayName)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles creating Video xblock and navigates to editor page', async () => {
|
||||
const { courseKey, locator } = courseCreateXblockMock;
|
||||
axiosMock
|
||||
|
||||
@@ -498,6 +498,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'other',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
|
||||
@@ -581,6 +582,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'video',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
|
||||
@@ -664,6 +666,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'other',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
|
||||
@@ -747,6 +750,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'video',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
|
||||
@@ -830,6 +834,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'other',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
|
||||
@@ -913,6 +918,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'video',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606',
|
||||
@@ -996,6 +1002,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'other',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
|
||||
@@ -1079,6 +1086,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'other',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193',
|
||||
@@ -1162,6 +1170,7 @@ module.exports = {
|
||||
selected_groups_label: '',
|
||||
},
|
||||
enable_copy_paste_units: false,
|
||||
xblock_type: 'video',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
84
src/course-unit/__mocks__/courseUnit.js
Normal file
84
src/course-unit/__mocks__/courseUnit.js
Normal file
@@ -0,0 +1,84 @@
|
||||
module.exports = {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2144',
|
||||
display_name: 'Getting Started new',
|
||||
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,
|
||||
xblock_type: 'other',
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as courseUnitIndexMock } from './courseUnitIndex';
|
||||
export { default as courseSectionVerticalMock } from './courseSectionVertical';
|
||||
export { default as courseUnitMock } from './courseUnit';
|
||||
export { default as courseCreateXblockMock } from './courseCreateXblock';
|
||||
|
||||
@@ -13,6 +13,7 @@ const Sequence = ({
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId,
|
||||
handleCreateNewCourseXblock,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { IN_PROGRESS, FAILED, SUCCESSFUL } = RequestStatus;
|
||||
@@ -26,6 +27,7 @@ const Sequence = ({
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
courseId={courseId}
|
||||
handleCreateNewCourseXblock={handleCreateNewCourseXblock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,6 +60,7 @@ Sequence.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
handleCreateNewCourseXblock: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Sequence.defaultProps = {
|
||||
|
||||
@@ -3,36 +3,23 @@ import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { getCourseSectionVertical, getSequenceStatus, sequenceIdsSelector } from '../data/selectors';
|
||||
import {
|
||||
getCourseSectionVertical,
|
||||
getCourseUnit,
|
||||
sequenceIdsSelector,
|
||||
} from '../data/selectors';
|
||||
|
||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||
const { SUCCESSFUL } = RequestStatus;
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequenceStatus = useSelector(getSequenceStatus);
|
||||
const { nextUrl, prevUrl } = useSelector(getCourseSectionVertical);
|
||||
const sequence = useModel('sequences', currentSequenceId);
|
||||
const { courseId, status } = useSelector(state => state.courseDetail);
|
||||
|
||||
const isCourseOrSequenceNotSuccessful = status !== SUCCESSFUL || sequenceStatus !== SUCCESSFUL;
|
||||
const areIdsNotValid = !currentSequenceId || !currentUnitId || !sequence.unitIds;
|
||||
const isNotSuccessfulCompletion = isCourseOrSequenceNotSuccessful || areIdsNotValid;
|
||||
|
||||
// If we don't know the sequence and unit yet, then assume no.
|
||||
if (isNotSuccessfulCompletion) {
|
||||
return { isFirstUnit: false, isLastUnit: false };
|
||||
}
|
||||
const { courseId } = useSelector(getCourseUnit);
|
||||
const isFirstUnit = !prevUrl;
|
||||
const isLastUnit = !nextUrl;
|
||||
|
||||
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
||||
|
||||
const isFirstSequence = sequenceIndex === 0;
|
||||
const isFirstUnitInSequence = unitIndex === 0;
|
||||
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
||||
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
|
||||
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
||||
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const SequenceNavigation = ({
|
||||
unitId,
|
||||
sequenceId,
|
||||
className,
|
||||
handleCreateNewCourseXblock,
|
||||
}) => {
|
||||
const sequenceStatus = useSelector(getSequenceStatus);
|
||||
const {
|
||||
@@ -42,6 +43,7 @@ const SequenceNavigation = ({
|
||||
<SequenceNavigationTabs
|
||||
unitIds={sequence.unitIds || []}
|
||||
unitId={unitId}
|
||||
handleCreateNewCourseXblock={handleCreateNewCourseXblock}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -105,6 +107,7 @@ SequenceNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
handleCreateNewCourseXblock: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
import { Button, Dropdown } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Plus as PlusIcon } from '@openedx/paragon/icons/';
|
||||
|
||||
import messages from '../messages';
|
||||
import UnitButton from './UnitButton';
|
||||
|
||||
const SequenceNavigationDropdown = ({ unitId, unitIds }) => {
|
||||
const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Dropdown className="sequence-navigation-dropdown">
|
||||
<Dropdown.Toggle variant="outline-primary" className="w-100">
|
||||
<Dropdown.Toggle id="sequence-navigation-dropdown" variant="outline-primary" className="w-100">
|
||||
{intl.formatMessage(messages.sequenceDropdownTitle, {
|
||||
current: unitIds.indexOf(unitId) + 1,
|
||||
total: unitIds.length,
|
||||
@@ -27,6 +28,14 @@ const SequenceNavigationDropdown = ({ unitId, unitIds }) => {
|
||||
unitId={buttonUnitId}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
as={Dropdown.Item}
|
||||
variant="outline-primary"
|
||||
iconBefore={PlusIcon}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitBtnText)}
|
||||
</Button>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
@@ -35,6 +44,7 @@ const SequenceNavigationDropdown = ({ unitId, unitIds }) => {
|
||||
SequenceNavigationDropdown.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SequenceNavigationDropdown;
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { Plus as PlusIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useIndexOfLastVisibleChild } from '../hooks';
|
||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice';
|
||||
import { getCourseId, getSequenceId } from '../../data/selectors';
|
||||
import { createCorrectInternalRoute } from '../../../utils';
|
||||
import messages from '../messages';
|
||||
import { useIndexOfLastVisibleChild } from '../hooks';
|
||||
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
|
||||
import UnitButton from './UnitButton';
|
||||
|
||||
const SequenceNavigationTabs = ({ unitIds, unitId }) => {
|
||||
const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXblock }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const sequenceId = useSelector(getSequenceId);
|
||||
const courseId = useSelector(getCourseId);
|
||||
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
containerRef,
|
||||
@@ -18,6 +27,14 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => {
|
||||
] = useIndexOfLastVisibleChild();
|
||||
const shouldDisplayDropdown = indexOfLastVisibleChild === -1;
|
||||
|
||||
const handleAddNewSequenceUnit = () => {
|
||||
dispatch(updateQueryPendingStatus(true));
|
||||
handleCreateNewCourseXblock({ parentLocator: sequenceId, category: 'vertical', displayName: 'Unit' }, ({ courseKey, locator }) => {
|
||||
navigate(createCorrectInternalRoute(`/course/${courseKey}/container/${locator}/${sequenceId}`), courseId);
|
||||
dispatch(changeEditTitleFormOpen(true));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sequence-navigation-tabs-wrapper">
|
||||
<div className="sequence-navigation-tabs-container d-flex" ref={containerRef}>
|
||||
@@ -32,13 +49,11 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => {
|
||||
isActive={unitId === buttonUnitId}
|
||||
/>
|
||||
))}
|
||||
{/* TODO: The functionality of the New unit button will be implemented in https://youtrack.raccoongang.com/issue/AXIMST-14 */}
|
||||
<Button
|
||||
className="sequence-navigation-tabs-new-unit-btn disabled"
|
||||
className="sequence-navigation-tabs-new-unit-btn"
|
||||
variant="outline-primary"
|
||||
iconBefore={PlusIcon}
|
||||
as={Link}
|
||||
to="/"
|
||||
onClick={handleAddNewSequenceUnit}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitBtnText)}
|
||||
</Button>
|
||||
@@ -48,6 +63,7 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => {
|
||||
<SequenceNavigationDropdown
|
||||
unitId={unitId}
|
||||
unitIds={unitIds}
|
||||
handleClick={handleAddNewSequenceUnit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -57,6 +73,7 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => {
|
||||
SequenceNavigationTabs.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleCreateNewCourseXblock: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SequenceNavigationTabs;
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Button } from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import UnitIcon from './UnitIcon';
|
||||
import { getCourseId, getSequenceId } from '../../data/selectors';
|
||||
|
||||
const UnitButton = ({
|
||||
title, contentType, isActive, unitId, className, showTitle,
|
||||
}) => {
|
||||
const courseId = useSelector(state => state.courseUnit.courseId);
|
||||
const sequenceId = useSelector(state => state.courseUnit.sequenceId);
|
||||
const courseId = useSelector(getCourseId);
|
||||
const sequenceId = useSelector(getSequenceId);
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -18,6 +19,7 @@ const UnitButton = ({
|
||||
as={Link}
|
||||
title={title}
|
||||
to={`/course/${courseId}/container/${unitId}/${sequenceId}/`}
|
||||
data-testid="course-unit-btn"
|
||||
>
|
||||
<UnitIcon type={contentType} />
|
||||
{showTitle && <span className="unit-title">{title}</span>}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import {
|
||||
normalizeLearningSequencesData,
|
||||
normalizeSequenceMetadata,
|
||||
normalizeMetadata,
|
||||
normalizeCourseHomeCourseMetadata,
|
||||
appendBrowserTimezoneToUrl,
|
||||
normalizeCourseSectionVerticalData,
|
||||
} from './utils';
|
||||
|
||||
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
@@ -17,7 +17,6 @@ export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/con
|
||||
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
|
||||
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
|
||||
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
|
||||
export const getSequenceMetadataApiUrl = (sequenceId) => `${getLmsBaseUrl()}/api/courseware/sequence/${sequenceId}`;
|
||||
export const getLearningSequencesOutlineApiUrl = (courseId) => `${getLmsBaseUrl()}/api/learning_sequences/v1/course_outline/${courseId}`;
|
||||
export const getCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/courseware/course/${courseId}`;
|
||||
export const getCourseHomeCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/course_home/course_metadata/${courseId}`;
|
||||
@@ -51,18 +50,6 @@ export async function editUnitDisplayName(unitId, displayName) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sequence metadata for a given sequence ID.
|
||||
* @param {string} sequenceId - The ID of the sequence for which metadata is requested.
|
||||
* @returns {Promise<Object>} - A Promise that resolves to the normalized sequence metadata.
|
||||
*/
|
||||
export async function getSequenceMetadata(sequenceId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getSequenceMetadataApiUrl(sequenceId), {});
|
||||
|
||||
return normalizeSequenceMetadata(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object containing course section vertical data.
|
||||
* @param {string} unitId
|
||||
@@ -72,7 +59,7 @@ export async function getCourseSectionVerticalData(unitId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseSectionVerticalApiUrl(unitId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
return normalizeCourseSectionVerticalData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,11 +101,14 @@ export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
||||
}
|
||||
|
||||
export async function createCourseXblock({ type, category, parentLocator }) {
|
||||
export async function createCourseXblock({
|
||||
type, category, parentLocator, displayName,
|
||||
}) {
|
||||
const body = {
|
||||
type,
|
||||
category: category || type,
|
||||
parent_locator: parentLocator,
|
||||
display_name: displayName,
|
||||
};
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postXBlockBaseApiUrl(), body);
|
||||
|
||||
@@ -3,20 +3,18 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
export const getCourseUnitData = (state) => state.courseUnit.unit;
|
||||
|
||||
export const getCourseUnit = (state) => state.courseUnit;
|
||||
export const getSavingStatus = (state) => state.courseUnit.savingStatus;
|
||||
|
||||
export const getLoadingStatus = (state) => state.courseUnit.loadingStatus;
|
||||
|
||||
export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus;
|
||||
|
||||
export const getCourseSectionVertical = (state) => state.courseUnit.courseSectionVertical;
|
||||
|
||||
export const getCourseSectionVerticalLoadingStatus = (state) => state
|
||||
.courseUnit.loadingStatus.courseSectionVerticalLoadingStatus;
|
||||
export const getCourseStatus = state => state.courseUnit.courseStatus;
|
||||
export const getCoursewareMeta = state => state.models.coursewareMeta;
|
||||
export const getSections = state => state.models.sections;
|
||||
export const getCourseId = state => state.courseDetail.courseId;
|
||||
|
||||
export const getSequenceId = state => state.courseUnit.sequenceId;
|
||||
export const sequenceIdsSelector = createSelector(
|
||||
[getCourseStatus, getCoursewareMeta, getSections, getCourseId],
|
||||
(courseStatus, coursewareMeta, sections, courseId) => {
|
||||
|
||||
@@ -7,6 +7,8 @@ const slice = createSlice({
|
||||
name: 'courseUnit',
|
||||
initialState: {
|
||||
savingStatus: '',
|
||||
isQueryPending: false,
|
||||
isEditTitleFormOpen: false,
|
||||
loadingStatus: {
|
||||
fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
@@ -24,6 +26,12 @@ const slice = createSlice({
|
||||
fetchUnitLoadingStatus: payload.status,
|
||||
};
|
||||
},
|
||||
updateQueryPendingStatus: (state, { payload }) => {
|
||||
state.isQueryPending = payload;
|
||||
},
|
||||
changeEditTitleFormOpen: (state, { payload }) => {
|
||||
state.isEditTitleFormOpen = payload;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
@@ -73,6 +81,12 @@ const slice = createSlice({
|
||||
createUnitXblockLoadingStatus: payload.status,
|
||||
};
|
||||
},
|
||||
addNewUnitStatus: (state, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
fetchUnitLoadingStatus: payload.status,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,6 +104,8 @@ export const {
|
||||
fetchCourseDenied,
|
||||
fetchCourseSectionVerticalDataSuccess,
|
||||
updateLoadingCourseSectionVerticalDataStatus,
|
||||
changeEditTitleFormOpen,
|
||||
updateQueryPendingStatus,
|
||||
updateLoadingCourseXblockStatus,
|
||||
} = slice.actions;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
getCourseUnitData,
|
||||
editUnitDisplayName,
|
||||
getSequenceMetadata,
|
||||
getCourseMetadata,
|
||||
getLearningSequencesOutline,
|
||||
getCourseHomeCourseMetadata,
|
||||
@@ -51,23 +50,34 @@ export function fetchCourseUnitQuery(courseId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSectionVerticalData(courseId) {
|
||||
export function fetchCourseSectionVerticalData(courseId, sequenceId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
dispatch(fetchSequenceRequest({ sequenceId }));
|
||||
|
||||
try {
|
||||
const courseSectionVerticalData = await getCourseSectionVerticalData(courseId);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: courseSectionVerticalData.sequence,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'units',
|
||||
models: courseSectionVerticalData.units,
|
||||
}));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(fetchSequenceFailure({ sequenceId }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseItemQuery(itemId, displayName) {
|
||||
export function editCourseItemQuery(itemId, displayName, sequenceId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -76,6 +86,18 @@ export function editCourseItemQuery(itemId, displayName) {
|
||||
await editUnitDisplayName(itemId, displayName).then(async (result) => {
|
||||
if (result) {
|
||||
const courseUnit = await getCourseUnitData(itemId);
|
||||
const courseSectionVerticalData = await getCourseSectionVerticalData(itemId);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: courseSectionVerticalData.sequence,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'units',
|
||||
models: courseSectionVerticalData.units,
|
||||
}));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
dispatch(fetchCourseItemSuccess(courseUnit));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
@@ -88,45 +110,6 @@ export function editCourseItemQuery(itemId, displayName) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSequence(sequenceId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchSequenceRequest({ sequenceId }));
|
||||
try {
|
||||
const { sequence, units } = await getSequenceMetadata(sequenceId);
|
||||
|
||||
if (sequence.blockType !== 'sequential') {
|
||||
// Some other block types (particularly 'chapter') can be returned
|
||||
// by this API. We want to error in that case, since downstream
|
||||
// courseware code is written to render Sequences of Units.
|
||||
logError(
|
||||
`Requested sequence '${sequenceId}' `
|
||||
+ `has block type '${sequence.blockType}'; expected block type 'sequential'.`,
|
||||
);
|
||||
dispatch(fetchSequenceFailure({ sequenceId }));
|
||||
} else {
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: sequence,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'units',
|
||||
models: units,
|
||||
}));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
}
|
||||
} catch (error) {
|
||||
// Some errors are expected - for example, CoursewareContainer may request sequence metadata for a unit and rely
|
||||
// on the request failing to notice that it actually does have a unit (mostly so it doesn't have to know anything
|
||||
// about the opaque key structure). In such cases, the backend gives us a 422.
|
||||
const sequenceMightBeUnit = error?.response?.status === 422;
|
||||
if (!sequenceMightBeUnit) {
|
||||
logError(error);
|
||||
}
|
||||
dispatch(fetchSequenceFailure({ sequenceId, sequenceMightBeUnit }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
@@ -156,9 +139,7 @@ export function fetchCourse(courseId) {
|
||||
}
|
||||
|
||||
if (learningSequencesOutlineResult.status === 'fulfilled') {
|
||||
const {
|
||||
courses, sections, sequences,
|
||||
} = learningSequencesOutlineResult.value;
|
||||
const { courses, sections } = learningSequencesOutlineResult.value;
|
||||
|
||||
// This updates the course with a sectionIds array from the Learning Sequence data.
|
||||
dispatch(updateModelsMap({
|
||||
@@ -169,11 +150,6 @@ export function fetchCourse(courseId) {
|
||||
modelType: 'sections',
|
||||
modelsMap: sections,
|
||||
}));
|
||||
// We update for sequences because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
@@ -225,6 +201,10 @@ export function createNewCourseXblock(body, callback) {
|
||||
try {
|
||||
await createCourseXblock(body).then(async (result) => {
|
||||
if (result) {
|
||||
if (body.category === 'vertical') {
|
||||
const courseSectionVerticalData = await getCourseSectionVerticalData(result.locator);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
}
|
||||
// ToDo: implement fetching (update) xblocks after success creating
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
|
||||
@@ -189,3 +189,25 @@ export function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCourseSectionVerticalData(metadata) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
...data,
|
||||
sequence: {
|
||||
id: data.subsectionLocation,
|
||||
title: data.xblock.displayName,
|
||||
unitIds: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((item) => item.id),
|
||||
},
|
||||
units: data.xblockInfo.ancestorInfo.ancestors[0].childInfo.children.map((unit) => ({
|
||||
id: unit.id,
|
||||
sequenceId: data.subsectionLocation,
|
||||
bookmarked: unit.bookmarked,
|
||||
complete: unit.complete,
|
||||
title: unit.displayName,
|
||||
contentType: unit.xblockType,
|
||||
graded: unit.graded,
|
||||
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form, IconButton } from '@openedx/paragon';
|
||||
@@ -7,24 +8,27 @@ import {
|
||||
Settings as SettingsIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { updateQueryPendingStatus } from '../data/slice';
|
||||
import messages from './messages';
|
||||
|
||||
const HeaderTitle = ({
|
||||
unitTitle,
|
||||
isTitleEditFormOpen,
|
||||
isEditTitleFormOpen,
|
||||
handleTitleEdit,
|
||||
handleTitleEditSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [titleValue, setTitleValue] = useState(unitTitle);
|
||||
|
||||
useEffect(() => {
|
||||
setTitleValue(unitTitle);
|
||||
dispatch(updateQueryPendingStatus(true));
|
||||
}, [unitTitle]);
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center lead">
|
||||
{isTitleEditFormOpen ? (
|
||||
{isEditTitleFormOpen ? (
|
||||
<Form.Group className="m-0">
|
||||
<Form.Control
|
||||
ref={(e) => e && e.focus()}
|
||||
@@ -59,7 +63,7 @@ const HeaderTitle = ({
|
||||
|
||||
HeaderTitle.propTypes = {
|
||||
unitTitle: PropTypes.string.isRequired,
|
||||
isTitleEditFormOpen: PropTypes.bool.isRequired,
|
||||
isEditTitleFormOpen: PropTypes.bool.isRequired,
|
||||
handleTitleEdit: PropTypes.func.isRequired,
|
||||
handleTitleEditSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import HeaderTitle from './HeaderTitle';
|
||||
import messages from './messages';
|
||||
|
||||
const unitTitle = 'Getting Started';
|
||||
const isTitleEditFormOpen = false;
|
||||
const isEditTitleFormOpen = false;
|
||||
const handleTitleEdit = jest.fn();
|
||||
const handleTitleEditSubmit = jest.fn();
|
||||
let store;
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<HeaderTitle
|
||||
unitTitle={unitTitle}
|
||||
isTitleEditFormOpen={isTitleEditFormOpen}
|
||||
handleTitleEdit={handleTitleEdit}
|
||||
handleTitleEditSubmit={handleTitleEditSubmit}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<HeaderTitle
|
||||
unitTitle={unitTitle}
|
||||
isEditTitleFormOpen={isEditTitleFormOpen}
|
||||
handleTitleEdit={handleTitleEdit}
|
||||
handleTitleEditSubmit={handleTitleEditSubmit}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<HeaderTitle />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('render HeaderTitle component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
|
||||
@@ -33,7 +52,7 @@ describe('<HeaderTitle />', () => {
|
||||
|
||||
it('render HeaderTitle with open edit form', () => {
|
||||
const { getByRole } = renderComponent({
|
||||
isTitleEditFormOpen: true,
|
||||
isEditTitleFormOpen: true,
|
||||
});
|
||||
|
||||
expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument();
|
||||
@@ -52,7 +71,7 @@ describe('<HeaderTitle />', () => {
|
||||
|
||||
it('calls saving title by clicking outside or press Enter key', async () => {
|
||||
const { getByRole } = renderComponent({
|
||||
isTitleEditFormOpen: true,
|
||||
isEditTitleFormOpen: true,
|
||||
});
|
||||
|
||||
const titleField = getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage });
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createNewCourseXblock,
|
||||
fetchCourseUnitQuery,
|
||||
editCourseItemQuery,
|
||||
fetchSequence,
|
||||
fetchCourse,
|
||||
fetchCourseSectionVerticalData,
|
||||
} from './data/thunk';
|
||||
@@ -17,18 +16,21 @@ import {
|
||||
getLoadingStatus,
|
||||
getSavingStatus,
|
||||
} from './data/selectors';
|
||||
import { updateSavingStatus } from './data/slice';
|
||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isErrorAlert, toggleErrorAlert] = useState(false);
|
||||
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
|
||||
const courseUnit = useSelector(getCourseUnitData);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical);
|
||||
const navigate = useNavigate();
|
||||
const [isTitleEditFormOpen, toggleTitleEditForm] = useState(false);
|
||||
const isEditTitleFormOpen = useSelector(state => state.courseUnit.isEditTitleFormOpen);
|
||||
const isQueryPending = useSelector(state => state.courseUnit.isQueryPending);
|
||||
|
||||
const unitTitle = courseUnit.metadata?.displayName || '';
|
||||
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
|
||||
@@ -43,16 +45,16 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
};
|
||||
|
||||
const handleInternetConnectionFailed = () => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
setInternetConnectionError(true);
|
||||
};
|
||||
|
||||
const handleTitleEdit = () => {
|
||||
toggleTitleEditForm(!isTitleEditFormOpen);
|
||||
dispatch(changeEditTitleFormOpen(!isEditTitleFormOpen));
|
||||
};
|
||||
|
||||
const handleTitleEditSubmit = (displayName) => {
|
||||
if (unitTitle !== displayName) {
|
||||
dispatch(editCourseItemQuery(blockId, displayName));
|
||||
dispatch(editCourseItemQuery(blockId, displayName, sequenceId));
|
||||
}
|
||||
|
||||
handleTitleEdit();
|
||||
@@ -68,11 +70,19 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
dispatch(createNewCourseXblock(body, callback))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateQueryPendingStatus(false));
|
||||
} else if (savingStatus === RequestStatus.FAILED && !hasInternetConnectionError) {
|
||||
toggleErrorAlert(true);
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseUnitQuery(blockId));
|
||||
dispatch(fetchCourseSectionVerticalData(blockId));
|
||||
dispatch(fetchSequence(sequenceId));
|
||||
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
|
||||
dispatch(fetchCourse(courseId));
|
||||
|
||||
handleNavigate(sequenceId);
|
||||
}, [courseId, blockId, sequenceId]);
|
||||
|
||||
@@ -80,8 +90,12 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
sequenceId,
|
||||
courseUnit,
|
||||
unitTitle,
|
||||
isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS,
|
||||
isTitleEditFormOpen,
|
||||
savingStatus,
|
||||
isQueryPending,
|
||||
isErrorAlert,
|
||||
isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS
|
||||
|| loadingStatus.courseSectionVerticalLoadingStatus === RequestStatus.IN_PROGRESS,
|
||||
isEditTitleFormOpen,
|
||||
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
|
||||
handleInternetConnectionFailed,
|
||||
headerNavigationsActions,
|
||||
|
||||
Reference in New Issue
Block a user