feat: section list and new section button

Also refactor api and hooks

fix: publish button behaviour and card header tests

fix: warning in highlights and publish modal test

fix: courseoutline tests

test: add test for new section functionality

fix(lint): lint issues

refactor: remove unnecessary css in CardHeader

refactor: rename emptyPlaceholder test file

refactor: replace ternary operator with 'and' condition

refactor: add black color to expand/collapse button

refactor: display only changed subsection and units in publish modal

refactor: update messages and css

refactor: wrap urls in function call

refactor: fix jsdoc types

refactor: use helmet for document title
This commit is contained in:
Navin Karkera
2023-11-20 15:52:20 +05:30
committed by Kristin Aoki
parent 59071424b3
commit 134b75568a
36 changed files with 484 additions and 176 deletions

View File

@@ -23,6 +23,7 @@ export const NOTIFICATION_MESSAGES = {
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
empty: '',
};
export const DEFAULT_TIME_STAMP = '00:00';

View File

@@ -2,11 +2,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Container,
Layout,
TransitionReplace,
} from '@edx/paragon';
import { Helmet } from 'react-helmet';
import {
Add as IconAdd,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
} from '@edx/paragon/icons';
@@ -66,10 +69,9 @@ const CourseOutline = ({ courseId }) => {
handleEditSectionSubmit,
handleDeleteSectionSubmit,
handleDuplicateSectionSubmit,
handleNewSectionSubmit,
} = useCourseOutline({ courseId });
document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle));
const {
isShow: isShowProcessingNotification,
title: processingNotificationTitle,
@@ -82,6 +84,9 @@ const CourseOutline = ({ courseId }) => {
return (
<>
<Helmet>
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
</Helmet>
<Container size="xl" className="px-4">
<section className="course-outline-container mb-4 mt-5">
<TransitionReplace>
@@ -131,20 +136,34 @@ const CourseOutline = ({ courseId }) => {
openEnableHighlightsModal={openEnableHighlightsModal}
/>
<div className="pt-4">
{/* TODO add create new section handler in EmptyPlaceholder */}
{sectionsList.length ? sectionsList.map((section) => (
<SectionCard
section={section}
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSectionSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
/>
)) : (
<EmptyPlaceholder onCreateNewSection={() => ({})} />
{sectionsList.length ? (
<>
{sectionsList.map((section) => (
<SectionCard
key={section.id}
section={section}
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSectionSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
/>
))}
<Button
data-testid="new-section-button"
className="mt-4"
variant="outline-primary"
onClick={handleNewSectionSubmit}
iconBefore={IconAdd}
block
>
{intl.formatMessage(messages.newSectionButton)}
</Button>
</>
) : (
<EmptyPlaceholder onCreateNewSection={handleNewSectionSubmit} />
)}
</div>
</section>

View File

@@ -1,5 +1,7 @@
import React from 'react';
import { render, waitFor, fireEvent } from '@testing-library/react';
import {
render, waitFor, cleanup, fireEvent,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -11,13 +13,13 @@ import {
getCourseLaunchApiUrl,
getCourseOutlineIndexApiUrl,
getCourseReindexApiUrl,
getCourseReindexApiUrl,
getCourseSectionApiUrl,
getCourseSectionDuplicateApiUrl,
getXBlockApiUrl,
getEnableHighlightsEmailsApiUrl,
getUpdateCourseSectionApiUrl,
getXBlockBaseApiUrl,
} from './data/api';
import {
addNewCourseSectionQuery,
deleteCourseSectionQuery,
duplicateCourseSectionQuery,
editCourseSectionQuery,
@@ -36,10 +38,12 @@ import {
courseOutlineIndexWithoutSections,
courseBestPracticesMock,
courseLaunchMock,
courseSectionMock,
} from './__mocks__';
import { executeThunk } from '../utils';
import CourseOutline from './CourseOutline';
import messages from './messages';
import headerMessages from './header-navigations/messages';
let axiosMock;
let store;
@@ -53,6 +57,15 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('../help-urls/hooks', () => ({
useHelpUrls: () => ({
contentHighlights: 'some',
visibility: 'some',
grading: 'some',
outline: 'some',
}),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
@@ -100,6 +113,25 @@ describe('<CourseOutline />', () => {
expect(getByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument();
});
it('adds new section correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
let element = await findAllByTestId('section-card');
expect(element.length).toBe(4);
axiosMock
.onPost(getXBlockBaseApiUrl())
.reply(200, {
locator: courseSectionMock.id,
});
axiosMock
.onGet(getXBlockApiUrl(courseSectionMock.id))
.reply(200, courseSectionMock);
await executeThunk(addNewCourseSectionQuery(courseId), store.dispatch);
element = await findAllByTestId('section-card');
expect(element.length).toBe(5);
});
it('render error alert after failed reindex correctly', async () => {
const { getByText } = render(<RootWrapper />);
@@ -163,11 +195,11 @@ describe('<CourseOutline />', () => {
const { queryAllByTestId, getByText } = render(<RootWrapper />);
await waitFor(() => {
const collapseBtn = getByText(messages.collapseAllButton.defaultMessage);
const collapseBtn = getByText(headerMessages.collapseAllButton.defaultMessage);
expect(collapseBtn).toBeInTheDocument();
fireEvent.click(collapseBtn);
const expendBtn = getByText(messages.expandAllButton.defaultMessage);
const expendBtn = getByText(headerMessages.expandAllButton.defaultMessage);
expect(expendBtn).toBeInTheDocument();
fireEvent.click(expendBtn);
@@ -210,7 +242,7 @@ describe('<CourseOutline />', () => {
await executeThunk(editCourseSectionQuery(section.id, newDisplayName), store.dispatch);
axiosMock
.onGet(getCourseSectionApiUrl(section.id))
.onGet(getXBlockApiUrl(section.id))
.reply(200);
await executeThunk(fetchCourseSectionQuery(section.id), store.dispatch);
@@ -237,7 +269,7 @@ describe('<CourseOutline />', () => {
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
axiosMock
.onPost(getCourseSectionDuplicateApiUrl())
.onPost(getXBlockBaseApiUrl())
.reply(200, {
duplicate_source_locator: section.id,
parent_locator: courseBlockId,
@@ -279,7 +311,7 @@ describe('<CourseOutline />', () => {
await executeThunk(publishCourseSectionQuery(section.id), store.dispatch);
axiosMock
.onGet(getCourseSectionApiUrl(section.id))
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...section,
published: true,
@@ -316,7 +348,7 @@ describe('<CourseOutline />', () => {
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
axiosMock
.onGet(getCourseSectionApiUrl(section.id))
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...section,
highlights,

View File

@@ -0,0 +1,93 @@
module.exports = {
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d0e78d363a424da6be5c22704c34f7a7',
display_name: 'Section',
category: 'chapter',
has_children: true,
edited_on: 'Nov 22, 2023 at 07:45 UTC',
published: true,
published_on: 'Nov 22, 2023 at 07:45 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d0e78d363a424da6be5c22704c34f7a7',
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',
highlights: [],
highlights_enabled: true,
highlights_preview_only: false,
highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
child_info: {
category: 'sequential',
display_name: 'Subsection',
children: [],
},
ancestor_has_staff_lock: false,
staff_only_message: false,
enable_copy_paste_units: false,
has_partition_group_components: 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: '',
},
};

View File

@@ -2,3 +2,4 @@ export { default as courseOutlineIndexMock } from './courseOutlineIndex';
export { default as courseOutlineIndexWithoutSections } from './courseOutlineIndexWithoutSections';
export { default as courseBestPracticesMock } from './courseBestPractices';
export { default as courseLaunchMock } from './courseLaunch';
export { default as courseSectionMock } from './courseSection';

View File

@@ -13,6 +13,7 @@ import {
} from '@edx/paragon';
import {
ArrowDropDown as ArrowDownIcon,
ArrowDropUp as ArrowUpIcon,
MoreVert as MoveVertIcon,
EditOutline as EditIcon,
} from '@edx/paragon/icons';
@@ -26,6 +27,7 @@ import messages from './messages';
const CardHeader = ({
title,
sectionStatus,
hasChanges,
isExpanded,
onClickPublish,
onClickMenuButton,
@@ -42,8 +44,8 @@ const CardHeader = ({
const [titleValue, setTitleValue] = useState(title);
const { badgeTitle, badgeIcon } = getSectionStatusBadgeContent(sectionStatus, messages, intl);
const isDisabledPublish = sectionStatus === SECTION_BADGE_STATUTES.live
|| sectionStatus === SECTION_BADGE_STATUTES.publishedNotLive;
const isDisabledPublish = (sectionStatus === SECTION_BADGE_STATUTES.live
|| sectionStatus === SECTION_BADGE_STATUTES.publishedNotLive) && !hasChanges;
useEscapeClick({
onEscape: () => {
@@ -86,12 +88,10 @@ const CardHeader = ({
)}
>
<Button
iconBefore={ArrowDownIcon}
iconBefore={isExpanded ? ArrowUpIcon : ArrowDownIcon}
variant="tertiary"
data-testid="section-card-header__expanded-btn"
className={classNames('section-card-header__expanded-btn', {
collapsed: !isExpanded,
})}
className="section-card-header__expanded-btn"
onClick={() => onExpand((prevState) => !prevState)}
>
<Truncate lines={1} className="h3 mb-0">{title}</Truncate>
@@ -149,6 +149,7 @@ const CardHeader = ({
CardHeader.propTypes = {
title: PropTypes.string.isRequired,
sectionStatus: PropTypes.string.isRequired,
hasChanges: PropTypes.bool.isRequired,
isExpanded: PropTypes.bool.isRequired,
onExpand: PropTypes.func.isRequired,
onClickPublish: PropTypes.func.isRequired,

View File

@@ -10,23 +10,7 @@
height: 1.5rem;
margin-right: .25rem;
background: transparent;
&::before {
display: none;
}
& svg {
width: 1.5rem;
height: 1.5rem;
}
&.collapsed > .pgn__icon {
transform: rotate(180deg);
}
& span:first-child {
color: $black;
}
color: $black;
}
.section-card-header__badge-status {
@@ -44,11 +28,6 @@
}
}
.section-card-header__menu {
display: flex;
align-items: center;
}
.pgn__form-group {
width: 80%;
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { SECTION_BADGE_STATUTES } from '../constants';
@@ -17,6 +17,7 @@ const closeFormMock = jest.fn();
const cardHeaderProps = {
title: 'Some title',
sectionStatus: SECTION_BADGE_STATUTES.live,
hasChanges: false,
isExpanded: true,
onExpand: onExpandMock,
onClickMenuButton: onClickMenuButtonMock,
@@ -40,137 +41,163 @@ const renderComponent = (props) => render(
);
describe('<CardHeader />', () => {
it('render CardHeader component correctly', () => {
const { getByText, getByTestId, queryByTestId } = renderComponent();
it('render CardHeader component correctly', async () => {
const { findByText, findByTestId, queryByTestId } = renderComponent();
expect(getByText(cardHeaderProps.title)).toBeInTheDocument();
expect(getByTestId('section-card-header__expanded-btn')).toBeInTheDocument();
expect(getByTestId('section-card-header__badge-status')).toBeInTheDocument();
expect(getByTestId('section-card-header__menu')).toBeInTheDocument();
expect(queryByTestId('edit field')).not.toBeInTheDocument();
expect(await findByText(cardHeaderProps.title)).toBeInTheDocument();
expect(await findByTestId('section-card-header__expanded-btn')).toBeInTheDocument();
expect(await findByTestId('section-card-header__badge-status')).toBeInTheDocument();
expect(await findByTestId('section-card-header__menu')).toBeInTheDocument();
await waitFor(() => {
expect(queryByTestId('edit field')).not.toBeInTheDocument();
});
});
it('render status badge as live', () => {
const { getByText } = renderComponent();
expect(getByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
it('render status badge as live', async () => {
const { findByText } = renderComponent();
expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
});
it('render status badge as published_not_live', () => {
const { getByText } = renderComponent({
it('render status badge as published_not_live', async () => {
const { findByText } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
});
expect(getByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
});
it('render status badge as staff_only', () => {
const { getByText } = renderComponent({
it('render status badge as staff_only', async () => {
const { findByText } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.staffOnly,
});
expect(getByText(messages.statusBadgeStuffOnly.defaultMessage)).toBeInTheDocument();
expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
});
it('render status badge as draft', () => {
const { getByText } = renderComponent({
it('render status badge as draft', async () => {
const { findByText } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.draft,
});
expect(getByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
});
it('check publish menu item is disabled when section status is live or published not live', async () => {
const { getByText, getByTestId } = renderComponent({
it('check publish menu item is disabled when section status is live or published not live and it has no changes', async () => {
const { findByText, findByTestId } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
});
const menuButton = getByTestId('section-card-header__menu-button');
const menuButton = await findByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
expect(getByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
});
it('calls handleExpanded when button is clicked', () => {
const { getByTestId } = renderComponent();
it('check publish menu item is enabled when section status is live or published not live and it has changes', async () => {
const { findByText, findByTestId } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
hasChanges: true,
});
const expandButton = getByTestId('section-card-header__expanded-btn');
const menuButton = await findByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
});
it('calls handleExpanded when button is clicked', async () => {
const { findByTestId } = renderComponent();
const expandButton = await findByTestId('section-card-header__expanded-btn');
fireEvent.click(expandButton);
expect(onExpandMock).toHaveBeenCalled();
});
it('calls onClickMenuButton when menu is clicked', () => {
const { getByTestId } = renderComponent();
it('calls onClickMenuButton when menu is clicked', async () => {
const { findByTestId } = renderComponent();
const menuButton = getByTestId('section-card-header__menu-button');
const menuButton = await findByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
expect(onClickMenuButtonMock).toHaveBeenCalled();
waitFor(() => {
expect(onClickMenuButtonMock).toHaveBeenCalled();
});
});
it('calls onClickPublish when item is clicked', () => {
const { getByText, getByTestId } = renderComponent({
it('calls onClickPublish when item is clicked', async () => {
const { findByText, findByTestId } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.draft,
});
const menuButton = getByTestId('section-card-header__menu-button');
const menuButton = await findByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
const publishMenuItem = getByText(messages.menuPublish.defaultMessage);
const publishMenuItem = await findByText(messages.menuPublish.defaultMessage);
fireEvent.click(publishMenuItem);
expect(onClickPublishMock).toHaveBeenCalled();
waitFor(() => {
expect(onClickPublishMock).toHaveBeenCalled();
});
});
it('calls onClickEdit when the button is clicked', () => {
const { getByTestId } = renderComponent();
it('calls onClickEdit when the button is clicked', async () => {
const { findByTestId } = renderComponent();
const editButton = getByTestId('edit-button');
const editButton = await findByTestId('edit-button');
fireEvent.click(editButton);
expect(onClickEditMock).toHaveBeenCalled();
waitFor(() => {
expect(onClickEditMock).toHaveBeenCalled();
});
});
it('check is field visible when isFormOpen is true', () => {
const { getByTestId, queryByTestId } = renderComponent({
it('check is field visible when isFormOpen is true', async () => {
const { findByTestId, queryByTestId } = renderComponent({
...cardHeaderProps,
isFormOpen: true,
});
expect(getByTestId('edit field')).toBeInTheDocument();
expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument();
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
expect(await findByTestId('edit field')).toBeInTheDocument();
waitFor(() => {
expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument();
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
});
});
it('check is field disabled when isDisabledEditField is true', () => {
const { getByTestId } = renderComponent({
it('check is field disabled when isDisabledEditField is true', async () => {
const { findByTestId } = renderComponent({
...cardHeaderProps,
isFormOpen: true,
isDisabledEditField: true,
});
expect(getByTestId('edit field')).toBeDisabled();
expect(await findByTestId('edit field')).toBeDisabled();
});
it('calls onClickDelete when item is clicked', () => {
const { getByText, getByTestId } = renderComponent();
it('calls onClickDelete when item is clicked', async () => {
const { findByText, findByTestId } = renderComponent();
const menuButton = getByTestId('section-card-header__menu-button');
const menuButton = await findByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
const deleteMenuItem = getByText(messages.menuDelete.defaultMessage);
const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage);
fireEvent.click(deleteMenuItem);
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
waitFor(() => {
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
});
});
it('calls onClickDuplicate when item is clicked', () => {
const { getByText, getByTestId } = renderComponent();
it('calls onClickDuplicate when item is clicked', async () => {
const { findByText, findByTestId } = renderComponent();
const menuButton = getByTestId('section-card-header__menu-button');
const menuButton = await findByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
const duplicateMenuItem = getByText(messages.menuDuplicate.defaultMessage);
const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage);
fireEvent.click(duplicateMenuItem);
expect(onClickDuplicateMock).toHaveBeenCalled();
waitFor(() => {
expect(onClickDuplicateMock).toHaveBeenCalled();
});
});
});

View File

@@ -13,7 +13,7 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.section.status-badge.published-not-live',
defaultMessage: 'Published not live',
},
statusBadgeStuffOnly: {
statusBadgeStaffOnly: {
id: 'course-authoring.course-outline.section.status-badge.staff-only',
defaultMessage: 'Staff only',
},

View File

@@ -1,21 +1,26 @@
export const SECTION_BADGE_STATUTES = {
export const SECTION_BADGE_STATUTES = /** @type {const} */ ({
live: 'live',
publishedNotLive: 'published_not_live',
staffOnly: 'staff_only',
draft: 'draft',
};
});
export const STAFF_ONLY = 'staff_only';
export const HIGHLIGHTS_FIELD_MAX_LENGTH = 250;
export const CHECKLIST_FILTERS = {
export const CHECKLIST_FILTERS = /** @type {const} */ ({
ALL: 'ALL',
SELF_PACED: 'SELF_PACED',
INSTRUCTOR_PACED: 'INSTRUCTOR_PACED',
};
});
export const LAUNCH_CHECKLIST = {
export const DEFAULT_NEW_DISPLAY_NAMES = /** @type {const} */ ({
chapter: 'Section',
subsection: 'Subsection',
});
export const LAUNCH_CHECKLIST = /** @type {const} */ ({
data: [
{
id: 'welcomeMessage',
@@ -42,9 +47,9 @@ export const LAUNCH_CHECKLIST = {
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
],
};
});
export const BEST_PRACTICES_CHECKLIST = {
export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({
data: [
{
id: 'videoDuration',
@@ -67,4 +72,4 @@ export const BEST_PRACTICES_CHECKLIST = {
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
],
};
});

View File

@@ -1,3 +1,4 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -24,14 +25,32 @@ export const getEnableHighlightsEmailsApiUrl = (courseId) => {
};
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
export const getUpdateCourseSectionApiUrl = (sectionId) => `${getApiBaseUrl()}/xblock/${sectionId}`;
export const getCourseSectionApiUrl = (sectionId) => `${getApiBaseUrl()}/xblock/outline/${sectionId}`;
export const getCourseSectionDuplicateApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getUpdateCourseSectionApiUrl = (sectionId) => `${getXBlockBaseApiUrl()}${sectionId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
/**
* @typedef {Object} courseOutline
* @property {string} courseReleaseDate
* @property {Object} courseStructure
* @property {Object} deprecatedBlocksInfo
* @property {string} discussionsIncontextFeedbackUrl
* @property {string} discussionsIncontextLearnmoreUrl
* @property {Object} initialState
* @property {Object} initialUserClipboard
* @property {string} languageCode
* @property {string} lmsLink
* @property {string} mfeProctoredExamSettingsUrl
* @property {string} notificationDismissUrl
* @property {string[]} proctoringErrors
* @property {string} reindexLink
* @property {null} rerunNotificationId
*/
/**
* Get course outline index.
* @param {string} courseId
* @returns {Promise<Object>}
* @returns {Promise<courseOutline>}
*/
export async function getCourseOutlineIndex(courseId) {
const { data } = await getAuthenticatedHttpClient()
@@ -42,10 +61,8 @@ export async function getCourseOutlineIndex(courseId) {
/**
* Get course best practices.
* @param {string} courseId
* @param {boolean} excludeGraded
* @param {boolean} all
* @returns {Promise<Object>}
* @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
* @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>}
*/
export async function getCourseBestPractices({
courseId,
@@ -58,13 +75,21 @@ export async function getCourseBestPractices({
return camelCaseObject(data);
}
/** @typedef {object} courseLaunchData
* @property {boolean} isSelfPaced
* @property {object} dates
* @property {object} assignments
* @property {object} grades
* @property {number} grades.sum_of_weights
* @property {object} certificates
* @property {object} updates
* @property {object} proctoring
*/
/**
* Get course launch.
* @param {string} courseId
* @param {boolean} gradedOnly
* @param {boolean} validateOras
* @param {boolean} all
* @returns {Promise<Object>}
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
* @returns {Promise<courseLaunchData>}
*/
export async function getCourseLaunch({
courseId,
@@ -109,14 +134,52 @@ export async function restartIndexingOnCourse(reindexLink) {
return camelCaseObject(data);
}
/**
* @typedef {Object} section
* @property {string} id
* @property {string} displayName
* @property {string} category
* @property {boolean} hasChildren
* @property {string} editedOn
* @property {boolean} published
* @property {string} publishedOn
* @property {string} studioUrl
* @property {boolean} releasedToStudents
* @property {string} releaseDate
* @property {string} visibilityState
* @property {boolean} hasExplicitStaffLock
* @property {string} start
* @property {boolean} graded
* @property {string} dueDate
* @property {null} due
* @property {null} relativeWeeksDue
* @property {null} format
* @property {string[]} courseGraders
* @property {boolean} hasChanges
* @property {object} actions
* @property {null} explanatoryMessage
* @property {object[]} userPartitions
* @property {string} showCorrectness
* @property {string[]} highlights
* @property {boolean} highlightsEnabled
* @property {boolean} highlightsPreviewOnly
* @property {string} highlightsDocUrl
* @property {object} childInfo
* @property {boolean} ancestorHasStaffLock
* @property {boolean} staffOnlyMessage
* @property {boolean} hasPartitionGroupComponents
* @property {object} userPartitionInfo
* @property {boolean} enableCopyPasteUnits
*/
/**
* Get course section
* @param {string} sectionId
* @returns {Promise<Object>}
* @returns {Promise<section>}
*/
export async function getCourseSection(sectionId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionApiUrl(sectionId));
.get(getXBlockApiUrl(sectionId));
return camelCaseObject(data);
}
@@ -189,10 +252,26 @@ export async function deleteCourseSection(sectionId) {
*/
export async function duplicateCourseSection(sectionId, courseBlockId) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseSectionDuplicateApiUrl(), {
.post(getXBlockBaseApiUrl(), {
duplicate_source_locator: sectionId,
parent_locator: courseBlockId,
});
return data;
}
/**
* Add new course item like section, subsection or unit.
* @param {string} courseBlockId
* @returns {Promise<Object>}
*/
export async function addNewCourseItem(courseBlockId, category, displayName) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: courseBlockId,
category,
display_name: displayName,
});
return data;
}

View File

@@ -74,6 +74,12 @@ const slice = createSlice({
setCurrentSection: (state, { payload }) => {
state.currentSection = payload;
},
addSection: (state, { payload }) => {
state.sectionsList = [
...state.sectionsList,
payload,
];
},
deleteSection: (state, { payload }) => {
state.sectionsList = state.sectionsList.filter(({ id }) => id !== payload);
},
@@ -89,6 +95,7 @@ const slice = createSlice({
});
export const {
addSection,
fetchOutlineIndexSuccess,
updateOutlineIndexLoadingStatus,
updateReindexLoadingStatus,

View File

@@ -1,5 +1,6 @@
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { DEFAULT_NEW_DISPLAY_NAMES } from '../constants';
import {
hideProcessingNotification,
showProcessingNotification,
@@ -9,6 +10,7 @@ import {
getCourseLaunchChecklist,
} from '../utils/getChecklistForStatusBar';
import {
addNewCourseItem,
deleteCourseSection,
duplicateCourseSection,
editCourseSection,
@@ -22,6 +24,7 @@ import {
updateCourseSectionHighlights,
} from './api';
import {
addSection,
fetchOutlineIndexSuccess,
updateOutlineIndexLoadingStatus,
updateReindexLoadingStatus,
@@ -231,3 +234,28 @@ export function duplicateCourseSectionQuery(sectionId, courseBlockId) {
}
};
}
export function addNewCourseSectionQuery(courseBlockId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await addNewCourseItem(
courseBlockId,
'chapter',
DEFAULT_NEW_DISPLAY_NAMES.chapter,
).then(async (result) => {
if (result) {
const data = await getCourseSection(result.locator);
dispatch(addSection(data));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -15,7 +15,6 @@ const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
return (
<AlertModal
title={intl.formatMessage(messages.title)}
variant="warning"
isOpen={isOpen}
onClose={close}
footerNode={(

View File

@@ -11,7 +11,7 @@ const messages = defineMessages({
},
deleteButton: {
id: 'course-authoring.course-outline.delete-modal.button.delete',
defaultMessage: 'Yes, delete this section',
defaultMessage: 'Delete',
},
cancelButton: {
id: 'course-authoring.course-outline.delete-modal.button.cancel',

View File

@@ -21,7 +21,8 @@ const EmptyPlaceholder = ({ onCreateNewSection }) => {
)}
>
<Button
variant="outline-success"
variant="primary"
size="sm"
iconBefore={IconAdd}
onClick={onCreateNewSection}
>

View File

@@ -22,6 +22,12 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('../../help-urls/hooks', () => ({
useHelpUrls: () => ({
contentHighlights: 'some',
}),
}));
const onEnableHighlightsSubmitMock = jest.fn();
const closeMock = jest.fn();

View File

@@ -57,7 +57,7 @@ const HeaderNavigations = ({
</Button>
</OverlayTrigger>
)}
{hasSections ? (
{hasSections && (
<Button
variant="outline-primary"
iconBefore={isSectionsExpanded ? ArrowUpIcon : ArrowDownIcon}
@@ -67,7 +67,7 @@ const HeaderNavigations = ({
? intl.formatMessage(messages.collapseAllButton)
: intl.formatMessage(messages.expandAllButton)}
</Button>
) : null}
)}
<OverlayTrigger
placement="bottom"
overlay={(

View File

@@ -1,7 +1,5 @@
import React from 'react';
import {
render, fireEvent, waitFor,
} from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';

View File

@@ -30,6 +30,12 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('../../help-urls/hooks', () => ({
useHelpUrls: () => ({
contentHighlights: 'some',
}),
}));
const currentSectionMock = {
highlights: ['Highlight 1', 'Highlight 2'],
displayName: 'Test Section',

View File

@@ -23,7 +23,7 @@ const messages = defineMessages({
},
saveButton: {
id: 'course-authoring.course-outline.highlights-modal.button.save',
defaultMessage: 'Save highlights',
defaultMessage: 'Save',
},
});

View File

@@ -16,6 +16,7 @@ import {
getCurrentSection,
} from './data/selectors';
import {
addNewCourseSectionQuery,
deleteCourseSectionQuery,
editCourseSectionQuery,
duplicateCourseSectionQuery,
@@ -31,7 +32,7 @@ import {
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
const { reindexLink, lmsLink } = useSelector(getOutlineIndexData);
const { reindexLink, courseStructure, lmsLink } = useSelector(getOutlineIndexData);
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
const statusBarData = useSelector(getStatusBarData);
const savingStatus = useSelector(getSavingStatus);
@@ -47,10 +48,12 @@ const useCourseOutline = ({ courseId }) => {
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const handleNewSectionSubmit = () => {
dispatch(addNewCourseSectionQuery(courseStructure.id));
};
const headerNavigationsActions = {
handleNewSection: () => {
// TODO add handler
},
handleNewSection: handleNewSectionSubmit,
handleReIndex: () => {
setDisableReindexButton(true);
setShowSuccessAlert(false);
@@ -154,6 +157,7 @@ const useCourseOutline = ({ courseId }) => {
openDeleteModal,
handleDeleteSectionSubmit,
handleDuplicateSectionSubmit,
handleNewSectionSubmit,
};
};

View File

@@ -29,13 +29,9 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.reindex.alert.error.title',
defaultMessage: 'There were errors reindexing course.',
},
expandAllButton: {
id: 'course-authoring.course-outline.header-navigations.button.expand-all',
defaultMessage: 'Expand all',
},
collapseAllButton: {
id: 'course-authoring.course-outline.header-navigations.button.collapse-all',
defaultMessage: 'Collapse all',
newSectionButton: {
id: 'course-authoring.course-outline.section-list.button.new-section',
defaultMessage: 'New section',
},
});

View File

@@ -36,8 +36,8 @@ const PublishModal = ({
</ModalDialog.Header>
<ModalDialog.Body>
<p className="small">{intl.formatMessage(messages.description)}</p>
{subSections.length ? subSections.map((subSection) => {
const units = subSection.childInfo.children;
{subSections.filter(subSection => subSection.hasChanges).map((subSection) => {
const units = subSection.childInfo.children.filter(unit => unit.hasChanges);
return units.length ? (
<React.Fragment key={subSection.id}>
@@ -52,7 +52,7 @@ const PublishModal = ({
))}
</React.Fragment>
) : null;
}) : null}
})}
</ModalDialog.Body>
<ModalDialog.Footer className="pt-1">
<ActionRow>

View File

@@ -35,28 +35,37 @@ const currentSectionMock = {
children: [
{
displayName: 'Subsection 1',
id: 1,
hasChanges: true,
childInfo: {
displayName: 'Unit',
children: [
{
id: 11,
displayName: 'Subsection_1 Unit 1',
hasChanges: true,
},
],
},
},
{
displayName: 'Subsection 2',
id: 2,
hasChanges: true,
childInfo: {
displayName: 'Unit',
children: [
{
id: 21,
displayName: 'Subsection_2 Unit 1',
hasChanges: true,
},
],
},
},
{
displayName: 'Subsection 3',
id: 3,
childInfo: {
children: [],
},

View File

@@ -34,9 +34,10 @@ const SectionCard = ({
const {
id,
displayName,
hasChanges,
published,
releasedToStudents,
visibleToStaffOnly,
visibleToStaffOnly = false,
visibilityState,
staffOnlyMessage,
highlights,
@@ -83,6 +84,7 @@ const SectionCard = ({
sectionId={id}
title={displayName}
sectionStatus={sectionStatus}
hasChanges={hasChanges}
isExpanded={isExpanded}
onExpand={handleExpandContent}
onClickMenuButton={handleClickMenuButton}
@@ -104,7 +106,7 @@ const SectionCard = ({
onClick={handleOpenHighlightsModal}
>
<Badge className="highlights-badge">{highlights.length}</Badge>
<p className="m-0 text-black">Section highlights</p>
<p className="m-0 text-black">{messages.sectionHighlightsBadge.defaultMessage}</p>
</Button>
</div>
</div>
@@ -137,8 +139,9 @@ SectionCard.propTypes = {
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
published: PropTypes.bool.isRequired,
hasChanges: PropTypes.bool.isRequired,
releasedToStudents: PropTypes.bool.isRequired,
visibleToStaffOnly: PropTypes.bool.isRequired,
visibleToStaffOnly: PropTypes.bool,
visibilityState: PropTypes.string.isRequired,
staffOnlyMessage: PropTypes.bool.isRequired,
highlights: PropTypes.arrayOf(PropTypes.string).isRequired,

View File

@@ -4,6 +4,7 @@
padding: $spacer 1.5rem 1.5rem;
cursor: move;
margin-bottom: 1.5rem;
background: $light-100;
.section-card__content {
margin-top: $spacer;

View File

@@ -21,6 +21,7 @@ const section = {
visibleToStaffOnly: false,
visibilityState: 'visible',
staffOnlyMessage: false,
hasChanges: false,
highlights: ['highlight 1', 'highlight 2'],
};

View File

@@ -5,6 +5,10 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.section.button.new-subsection',
defaultMessage: 'New subsection',
},
sectionHighlightsBadge: {
id: 'course-authoring.course-outline.section.badge.section-highlights',
defaultMessage: 'Section highlights',
},
});
export default messages;

View File

@@ -31,8 +31,8 @@ const StatusBar = ({
} = checklist;
const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`;
const checklistDestination = new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href;
const scheduleDestination = new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href;
const checklistDestination = () => new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href;
const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href;
const {
contentHighlights: contentHighlightsUrl,
@@ -49,7 +49,7 @@ const StatusBar = ({
<h5>{intl.formatMessage(messages.startDateTitle)}</h5>
<Hyperlink
className="small"
destination={scheduleDestination}
destination={scheduleDestination()}
showLaunchIcon={false}
>
{courseReleaseDate}
@@ -67,7 +67,7 @@ const StatusBar = ({
<h5>{intl.formatMessage(messages.checklistTitle)}</h5>
<Hyperlink
className="small"
destination={checklistDestination}
destination={checklistDestination()}
showLaunchIcon={false}
>
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}

View File

@@ -21,6 +21,12 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('../../help-urls/hooks', () => ({
useHelpUrls: () => ({
contentHighlights: 'some',
}),
}));
const statusBarData = {
courseReleaseDate: 'Feb 05, 2013 at 05:00 UTC',
isSelfPaced: true,

View File

@@ -13,7 +13,7 @@ import { SECTION_BADGE_STATUTES, STAFF_ONLY } from './constants';
* @param {bool} visibleToStaffOnly - value from section info
* @param {string} visibilityState - value from section info
* @param {bool} staffOnlyMessage - value from section info
* @returns {typeof SECTION_BADGE_STATUTES}
* @returns {SECTION_BADGE_STATUTES[keyof SECTION_BADGE_STATUTES]}
*/
const getSectionStatus = ({
published,
@@ -58,7 +58,7 @@ const getSectionStatusBadgeContent = (status, messages, intl) => {
};
case SECTION_BADGE_STATUTES.staffOnly:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeStuffOnly),
badgeTitle: intl.formatMessage(messages.statusBadgeStaffOnly),
badgeIcon: LockIcon,
};
case SECTION_BADGE_STATUTES.draft:

View File

@@ -31,8 +31,8 @@ const CourseUploadImage = ({
}) => {
const { courseId } = useParams();
const intl = useIntl();
const imageAbsolutePath = new URL(assetImagePath, getConfig().LMS_BASE_URL);
const assetsUrl = new URL(`/assets/${courseId}`, getConfig().STUDIO_BASE_URL);
const imageAbsolutePath = () => new URL(assetImagePath, getConfig().LMS_BASE_URL);
const assetsUrl = () => new URL(`/assets/${courseId}`, getConfig().STUDIO_BASE_URL);
const handleChangeImageAsset = (path) => {
const assetPath = _.last(path.split('/'));
@@ -59,7 +59,7 @@ const CourseUploadImage = ({
const inputComponent = assetImagePath ? (
<div className="image-preview">
<Image
src={imageAbsolutePath.href}
src={imageAbsolutePath().href}
alt={intl.formatMessage(messages.uploadImageDropzoneAlt)}
fluid
/>
@@ -88,7 +88,7 @@ const CourseUploadImage = ({
values={{
hyperlink: (
<Hyperlink
destination={assetsUrl.href}
destination={assetsUrl().href}
target="_blank"
showLaunchIcon={false}
>

View File

@@ -1,9 +1,11 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { NOTIFICATION_MESSAGES } from '../../../constants';
const initialState = {
isShow: false,
title: '',
title: NOTIFICATION_MESSAGES.empty,
};
const slice = createSlice({

View File

@@ -17,7 +17,7 @@ const CardItem = ({
courseCreatorStatus,
rerunCreatorStatus,
} = useSelector(getStudioHomeData);
const courseUrl = new URL(url, getConfig().STUDIO_BASE_URL);
const courseUrl = () => new URL(url, getConfig().STUDIO_BASE_URL);
const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`;
const readOnlyItem = !(lmsLink || rerunLink || url);
const showActions = !(readOnlyItem || isLibraries);
@@ -32,7 +32,7 @@ const CardItem = ({
title={!readOnlyItem ? (
<Hyperlink
className="card-item-title"
destination={courseUrl.toString()}
destination={courseUrl().toString()}
>
{displayName}
</Hyperlink>