feat: Unit creation button logic and refactoring

This commit is contained in:
Peter Kulko
2024-01-15 14:34:42 +02:00
committed by Adolfo R. Brandes
parent 90fb3d8edc
commit 7fcc501d2e
20 changed files with 393 additions and 138 deletions

View File

@@ -73,6 +73,7 @@ const CourseAuthoringRoutes = () => {
/>
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
<Route
key={path}
path={path}
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
/>

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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