feat: Created Course updates page (#581)

This commit is contained in:
vladislavkeblysh
2023-08-31 17:56:45 +03:00
committed by GitHub
parent 181f9c7a5f
commit ffae3bd868
61 changed files with 2241 additions and 8 deletions

1
.env
View File

@@ -32,7 +32,6 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_UPDATES_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false

View File

@@ -34,7 +34,6 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
ENABLE_NEW_UPDATES_PAGE = false
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
ENABLE_NEW_GRADING_PAGE = false
ENABLE_NEW_COURSE_TEAM_PAGE = false

View File

@@ -30,7 +30,6 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_NEW_HOME_PAGE = false
ENABLE_NEW_COURSE_OUTLINE_PAGE = true
ENABLE_NEW_UPDATES_PAGE = true
ENABLE_NEW_VIDEO_UPLOAD_PAGE = true
ENABLE_NEW_GRADING_PAGE = true
ENABLE_NEW_COURSE_TEAM_PAGE = true

View File

@@ -14,6 +14,7 @@ import { AdvancedSettings } from './advanced-settings';
import ScheduleAndDetails from './schedule-and-details';
import { GradingSettings } from './grading-settings';
import CourseTeam from './course-team/CourseTeam';
import { CourseUpdates } from './course-updates';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -43,10 +44,7 @@ const CourseAuthoringRoutes = ({ courseId }) => {
)}
</PageRoute>
<PageRoute path={`${path}/course_info`}>
{process.env.ENABLE_NEW_UPDATES_PAGE === 'true'
&& (
<Placeholder />
)}
<CourseUpdates courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/assets`}>
<FilesAndUploads courseId={courseId} />

View File

@@ -0,0 +1,9 @@
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,7 +1,7 @@
export const DATE_FORMAT = 'MM/dd/yyyy';
export const TIME_FORMAT = 'HH:mm';
export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss\\Z';
export const FORMATTED_DATE_FORMAT = 'MMMM D, YYYY';
export const COMMA_SEPARATED_DATE_FORMAT = 'MMMM D, YYYY';
export const DEFAULT_EMPTY_WYSIWYG_VALUE = '<p>&nbsp;</p>';
export const STATEFUL_BUTTON_STATES = {
pending: 'pending',
@@ -17,3 +17,9 @@ export const BADGE_STATES = {
danger: 'danger',
secondary: 'secondary',
};
export const NOTIFICATION_MESSAGES = {
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
};

View File

@@ -0,0 +1,163 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Container,
Layout,
} from '@edx/paragon';
import { Add as AddIcon } from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import ProcessingNotification from '../generic/processing-notification';
import SubHeader from '../generic/sub-header/SubHeader';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import { RequestStatus } from '../data/constants';
import CourseHandouts from './course-handouts/CourseHandouts';
import CourseUpdate from './course-update/CourseUpdate';
import DeleteModal from './delete-modal/DeleteModal';
import UpdateForm from './update-form/UpdateForm';
import { REQUEST_TYPES } from './constants';
import messages from './messages';
import { useCourseUpdates } from './hooks';
import { getLoadingStatuses, getSavingStatuses } from './data/selectors';
import { matchesAnyStatus } from './utils';
const CourseUpdates = ({ courseId }) => {
const intl = useIntl();
const {
requestType,
courseUpdates,
courseHandouts,
courseUpdatesInitialValues,
isMainFormOpen,
isInnerFormOpen,
isUpdateFormOpen,
isDeleteModalOpen,
closeUpdateForm,
closeDeleteModal,
handleUpdatesSubmit,
handleOpenUpdateForm,
handleOpenDeleteForm,
handleDeleteUpdateSubmit,
} = useCourseUpdates({ courseId });
const {
isShow: isShowProcessingNotification,
title: processingNotificationTitle,
} = useSelector(getProcessingNotification);
const loadingStatuses = useSelector(getLoadingStatuses);
const savingStatuses = useSelector(getSavingStatuses);
const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED);
const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS);
const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING);
return (
<>
<Container size="xl" className="m-4">
<section className="setting-items mb-4 mt-5">
<Layout
lg={[{ span: 12 }]}
md={[{ span: 12 }]}
sm={[{ span: 12 }]}
xs={[{ span: 12 }]}
xl={[{ span: 12 }]}
>
<Layout.Element>
<article>
<div>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
instruction={intl.formatMessage(messages.sectionInfo)}
headerActions={(
<Button
variant="outline-primary"
iconBefore={AddIcon}
size="sm"
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
disabled={isUpdateFormOpen}
>
{intl.formatMessage(messages.newUpdateButton)}
</Button>
)}
/>
<section className="updates-section">
{isMainFormOpen && (
<UpdateForm
isOpen={isUpdateFormOpen}
close={closeUpdateForm}
requestType={requestType}
onSubmit={handleUpdatesSubmit}
courseUpdatesInitialValues={courseUpdatesInitialValues}
/>
)}
<div className="updates-container">
<div className="p-4.5">
{courseUpdates.length ? courseUpdates.map((courseUpdate, index) => (
isInnerFormOpen(courseUpdate.id) ? (
<UpdateForm
isOpen={isUpdateFormOpen}
close={closeUpdateForm}
requestType={requestType}
isInnerForm
isFirstUpdate={index === 0}
onSubmit={handleUpdatesSubmit}
courseUpdatesInitialValues={courseUpdatesInitialValues}
/>
) : (
<CourseUpdate
dateForUpdate={courseUpdate.date}
contentForUpdate={courseUpdate.content}
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_update, courseUpdate)}
onDelete={() => handleOpenDeleteForm(courseUpdate)}
isDisabledButtons={isUpdateFormOpen}
/>
))) : null}
</div>
<div className="updates-handouts-container">
<CourseHandouts
contentForHandouts={courseHandouts?.data || ''}
onEdit={() => handleOpenUpdateForm(REQUEST_TYPES.edit_handouts)}
isDisabledButtons={isUpdateFormOpen}
/>
</div>
<DeleteModal
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={handleDeleteUpdateSubmit}
/>
{isShowProcessingNotification && (
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
)}
</div>
</section>
</div>
</article>
</Layout.Element>
</Layout>
</section>
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={anyStatusFailed}
isQueryPending={anyStatusInProgress || anyStatusPending}
onInternetConnectionFailed={() => null}
/>
</div>
</>
);
};
CourseUpdates.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default CourseUpdates;

View File

@@ -0,0 +1,20 @@
@import "./course-handouts/CourseHandouts";
@import "./course-update/CourseUpdate";
@import "./update-form/UpdateForm";
.updates-container {
@include pgn-box-shadow(1, "centered");
display: grid;
grid-template-columns: 65% 35%;
border: .0625rem solid $gray-200;
border-radius: .375rem;
background: $white;
overflow: hidden;
}
.updates-handouts-container {
border-left: .0625rem solid $gray-200;
padding: 1.875rem;
background: $white;
}

View File

@@ -0,0 +1,220 @@
import React from 'react';
import { render, waitFor, 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';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
getCourseUpdatesApiUrl,
getCourseHandoutApiUrl,
updateCourseUpdatesApiUrl,
} from './data/api';
import {
createCourseUpdateQuery,
deleteCourseUpdateQuery,
editCourseHandoutsQuery,
editCourseUpdateQuery,
} from './data/thunk';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { courseUpdatesMock, courseHandoutsMock } from './__mocks__';
import CourseUpdates from './CourseUpdates';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
jest.mock('@tinymce/tinymce-react', () => {
const originalModule = jest.requireActual('@tinymce/tinymce-react');
return {
__esModule: true,
...originalModule,
Editor: () => 'foo bar',
};
});
jest.mock('@edx/frontend-lib-content-components', () => ({
TinyMceWidget: () => <div>Widget</div>,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<CourseUpdates courseId={courseId} />
</IntlProvider>
</AppProvider>
);
describe('<CourseUpdates />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUpdatesApiUrl(courseId))
.reply(200, courseUpdatesMock);
axiosMock
.onGet(getCourseHandoutApiUrl(courseId))
.reply(200, courseHandoutsMock);
});
it('render CourseUpdates component correctly', async () => {
const {
getByText, getAllByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sectionInfo.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeInTheDocument();
expect(getAllByTestId('course-update')).toHaveLength(3);
expect(getByTestId('course-handouts')).toBeInTheDocument();
});
});
it('should create course update', async () => {
const { getByText } = render(<RootWrapper />);
const data = {
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPost(getCourseUpdatesApiUrl(courseId))
.reply(200, data);
await executeThunk(createCourseUpdateQuery(courseId, data), store.dispatch);
expect(getByText('Some text')).toBeInTheDocument();
expect(getByText(data.date)).toBeInTheDocument();
});
it('should edit course update', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
id: courseUpdatesMock[0].id,
content: '<p>Some text</p>',
date: 'August 29, 2023',
};
axiosMock
.onPut(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200, data);
await executeThunk(editCourseUpdateQuery(courseId, data), store.dispatch);
expect(getByText('Some text')).toBeInTheDocument();
expect(getByText(data.date)).toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should delete course update', async () => {
const { queryByText } = render(<RootWrapper />);
axiosMock
.onDelete(updateCourseUpdatesApiUrl(courseId, courseUpdatesMock[0].id))
.reply(200);
await executeThunk(deleteCourseUpdateQuery(courseId, courseUpdatesMock[0].id), store.dispatch);
expect(queryByText(courseUpdatesMock[0].date)).not.toBeInTheDocument();
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
it('should edit course handouts', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
const data = {
...courseHandoutsMock,
data: '<p>Some handouts 1</p>',
};
axiosMock
.onPut(getCourseHandoutApiUrl(courseId))
.reply(200, data);
await executeThunk(editCourseHandoutsQuery(courseId, data), store.dispatch);
expect(getByText('Some handouts 1')).toBeInTheDocument();
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
});
it('Add new update form is visible after clicking "New update" button', async () => {
const { getByText, getByRole, getAllByRole } = render(<RootWrapper />);
await waitFor(() => {
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
const newUpdateButton = getByRole('button', { name: messages.newUpdateButton.defaultMessage });
fireEvent.click(newUpdateButton);
expect(newUpdateButton).toBeDisabled();
editButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(getByText('Add new update')).toBeInTheDocument();
});
});
it('Edit handouts form is visible after clicking "Edit" button', async () => {
const {
getByText, getByRole, getByTestId, getAllByRole,
} = render(<RootWrapper />);
await waitFor(() => {
const editHandoutsButton = getByTestId('course-handouts-edit-button');
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
fireEvent.click(editHandoutsButton);
expect(editHandoutsButton).toBeDisabled();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(getByText('Edit handouts')).toBeInTheDocument();
});
});
it('Edit update form is visible after clicking "Edit" button', async () => {
const {
getByText, getByRole, getAllByTestId, getAllByRole, queryByText,
} = render(<RootWrapper />);
await waitFor(() => {
const editUpdateFirstButton = getAllByTestId('course-update-edit-button')[0];
const editButtons = getAllByRole('button', { name: 'Edit' });
const deleteButtons = getAllByRole('button', { name: 'Delete' });
fireEvent.click(editUpdateFirstButton);
expect(getByText('Edit update')).toBeInTheDocument();
expect(getByRole('button', { name: messages.newUpdateButton.defaultMessage })).toBeDisabled();
editButtons.forEach((button) => expect(button).toBeDisabled());
deleteButtons.forEach((button) => expect(button).toBeDisabled());
expect(queryByText(courseUpdatesMock[0].content)).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,83 @@
module.exports = {
id: 'block-v1:edX+DemoX+Demo_Course+type@course_info+block@handouts',
display_name: 'Text',
category: 'course_info',
has_children: false,
edited_on: 'Jul 12, 2023 at 17:52 UTC',
published: true,
published_on: 'Jul 12, 2023 at 17:52 UTC',
studio_url: null,
released_to_students: false,
release_date: null,
visibility_state: 'unscheduled',
has_explicit_staff_lock: false,
start: '2030-01-01T00:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: null,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
data: 'Some handouts',
metadata: {},
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: '',
},
};

View File

@@ -0,0 +1,5 @@
module.exports = [
{ id: 1, date: 'July 11, 2023', content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' },
{ id: 2, date: 'August 20, 2023', content: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.' },
{ id: 3, date: 'January 30, 2023', content: 'But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself' },
];

View File

@@ -0,0 +1,2 @@
export { default as courseUpdatesMock } from './courseUpdates';
export { default as courseHandoutsMock } from './courseHandouts';

View File

@@ -0,0 +1,7 @@
// eslint-disable-next-line import/prefer-default-export
export const REQUEST_TYPES = {
add_new_update: 'add_new_update',
edit_update: 'edit_update',
edit_handouts: 'edit_handouts',
delete_update: 'delete_update',
};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const CourseHandouts = ({ contentForHandouts, onEdit, isDisabledButtons }) => {
const intl = useIntl();
return (
<div className="course-handouts" data-testid="course-handouts">
<div className="course-handouts-header">
<h2 className="course-handouts-header__title lead">{intl.formatMessage(messages.handoutsTitle)}</h2>
<Button
className="course-handouts-header__btn"
data-testid="course-handouts-edit-button"
variant="outline-primary"
size="sm"
onClick={onEdit}
disabled={isDisabledButtons}
>
{intl.formatMessage(messages.editButton)}
</Button>
</div>
<div
className="small"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: contentForHandouts || '' }}
/>
</div>
);
};
CourseHandouts.propTypes = {
contentForHandouts: PropTypes.string.isRequired,
onEdit: PropTypes.func.isRequired,
isDisabledButtons: PropTypes.bool.isRequired,
};
export default CourseHandouts;

View File

@@ -0,0 +1,16 @@
.course-handouts {
.course-handouts-header {
display: flex;
justify-content: space-between;
margin-bottom: $spacer;
.course-handouts-header__title {
font-weight: 300;
color: $gray-800;
}
.course-handouts-header__btn {
align-self: flex-start;
}
}
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import CourseHandouts from './CourseHandouts';
import messages from './messages';
const onEditMock = jest.fn();
const handoutsContentMock = 'Handouts Content';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CourseHandouts
onEdit={onEditMock}
contentForHandouts={handoutsContentMock}
isDisabledButtons={false}
{...props}
/>
</IntlProvider>,
);
describe('<CourseHandouts />', () => {
it('render CourseHandouts component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(messages.handoutsTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(handoutsContentMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
});
it('calls the onEdit function when the edit button is clicked', () => {
const { getByRole } = renderComponent();
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(onEditMock).toHaveBeenCalledTimes(1);
});
it('"Edit" button is disabled when isDisabledButtons is true', () => {
const { getByRole } = renderComponent({ isDisabledButtons: true });
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
expect(editButton).toBeDisabled();
});
});

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
handoutsTitle: {
id: 'course-authoring.course-updates.handouts.title',
defaultMessage: 'Course handouts',
},
editButton: {
id: 'course-authoring.course-updates.actions.edit',
defaultMessage: 'Edit',
},
});
export default messages;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Icon } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Error as ErrorIcon } from '@edx/paragon/icons/es5';
import { isDateForUpdateValid } from './utils';
import messages from './messages';
const CourseUpdate = ({
dateForUpdate,
contentForUpdate,
onEdit,
onDelete,
isDisabledButtons,
}) => {
const intl = useIntl();
return (
<div className="course-update" data-testid="course-update">
<div className="course-update-header">
<span className="course-update-header__date small font-weight-bold">{dateForUpdate}</span>
{!isDateForUpdateValid(dateForUpdate) && (
<div className="course-update-header__error">
<Icon src={ErrorIcon} alt={intl.formatMessage(messages.errorMessage)} />
<p className="message-error small m-0">{intl.formatMessage(messages.errorMessage)}</p>
</div>
)}
<div className="course-update-header__action">
<Button
variant="outline-primary"
size="sm"
onClick={onEdit}
disabled={isDisabledButtons}
data-testid="course-update-edit-button"
>
{intl.formatMessage(messages.editButton)}
</Button>
<Button variant="outline-primary" size="sm" onClick={onDelete} disabled={isDisabledButtons}>
{intl.formatMessage(messages.deleteButton)}
</Button>
</div>
</div>
{Boolean(contentForUpdate) && (
<div
className="small text-gray-800"
data-testid="course-update-content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: contentForUpdate }}
/>
)}
</div>
);
};
CourseUpdate.propTypes = {
dateForUpdate: PropTypes.string.isRequired,
contentForUpdate: PropTypes.string.isRequired,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
isDisabledButtons: PropTypes.bool.isRequired,
};
export default CourseUpdate;

View File

@@ -0,0 +1,36 @@
.course-update {
&:not(:first-child) {
padding-top: 1.875rem;
margin-top: 1.875rem;
border-top: 1px solid $light-400;
}
.course-update-header {
display: flex;
align-items: center;
margin-bottom: 1.125rem;
gap: .5rem;
.course-update-header__date {
line-height: 1.875rem;
letter-spacing: 1px;
}
.course-update-header__error {
display: flex;
align-items: center;
gap: .25rem;
svg {
color: $warning-300;
}
}
.course-update-header__action {
display: flex;
width: auto;
margin-left: auto;
gap: .5rem;
}
}
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import CourseUpdate from './CourseUpdate';
import messages from './messages';
const onEditMock = jest.fn();
const onDeleteMock = jest.fn();
const dateForUpdateMock = 'May 1, 2023';
const contentForUpdateMock = 'Update Content';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CourseUpdate
dateForUpdate={dateForUpdateMock}
contentForUpdate={contentForUpdateMock}
onEdit={onEditMock}
onDelete={onDeleteMock}
isDisabledButtons={false}
{...props}
/>
</IntlProvider>,
);
describe('<CourseUpdate />', () => {
it('render CourseUpdate component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
it('render CourseUpdate component without content correctly', () => {
const { getByText, queryByTestId, getByRole } = renderComponent({ contentForUpdate: '' });
expect(getByText(dateForUpdateMock)).toBeInTheDocument();
expect(queryByTestId('course-update-content')).not.toBeInTheDocument();
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
it('render error message when dateForUpdate is inValid', () => {
const { getByText } = renderComponent({ dateForUpdate: 'Welcome' });
expect(getByText(messages.errorMessage.defaultMessage)).toBeInTheDocument();
});
it('calls the onEdit function when the "Edit" button is clicked', () => {
const { getByRole } = renderComponent();
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(onEditMock).toHaveBeenCalledTimes(1);
});
it('calls the onDelete function when the "Delete" button is clicked', () => {
const { getByRole } = renderComponent();
const deleteButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
fireEvent.click(deleteButton);
expect(onDeleteMock).toHaveBeenCalledTimes(1);
});
it('"Edit" and "Delete" buttons is disabled when isDisabledButtons is true', () => {
const { getByRole } = renderComponent({ isDisabledButtons: true });
expect(getByRole('button', { name: messages.editButton.defaultMessage })).toBeDisabled();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeDisabled();
});
});

View File

@@ -0,0 +1,18 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
editButton: {
id: 'course-authoring.course-updates.button.edit',
defaultMessage: 'Edit',
},
deleteButton: {
id: 'course-authoring.course-updates.button.delete',
defaultMessage: 'Delete',
},
errorMessage: {
id: 'course-authoring.course-updates.date-invalid',
defaultMessage: 'Action required: Enter a valid date.',
},
});
export default messages;

View File

@@ -0,0 +1,17 @@
import moment from 'moment';
import { COMMA_SEPARATED_DATE_FORMAT } from '../../constants';
/**
* Check is valid date format in course update
* @param {string} date - date for update
* @returns {boolean} - is valid date format
*/
const isDateForUpdateValid = (date) => {
const parsedDate = moment(date, COMMA_SEPARATED_DATE_FORMAT, true);
return parsedDate.isValid() && parsedDate.format(COMMA_SEPARATED_DATE_FORMAT) === date;
};
// eslint-disable-next-line import/prefer-default-export
export { isDateForUpdateValid };

View File

@@ -0,0 +1,84 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseUpdatesApiUrl = (courseId) => `${getApiBaseUrl()}/course_info_update/${courseId}/`;
export const updateCourseUpdatesApiUrl = (courseId, updateId) => `${getApiBaseUrl()}/course_info_update/${courseId}/${updateId}`;
export const getCourseHandoutApiUrl = (courseId) => {
const formattedCourseId = courseId.split('course-v1:')[1];
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course_info+block@handouts`;
};
/**
* Get course updates.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseUpdates(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseUpdatesApiUrl(courseId));
return data;
}
/**
* Create new course update.
* @param {string} courseId
* @param {object} courseUpdate
* @returns {Promise<Object>}
*/
export async function createUpdate(courseId, courseUpdate) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseUpdatesApiUrl(courseId), courseUpdate);
return data;
}
/**
* Edit course update.
* @param {string} courseId
* @param {object} courseUpdate
* @returns {Promise<Object>}
*/
export async function editUpdate(courseId, courseUpdate) {
const { data } = await getAuthenticatedHttpClient()
.put(updateCourseUpdatesApiUrl(courseId, courseUpdate.id), courseUpdate);
return data;
}
/**
* Delete course update.
* @param {string} courseId
* @param {number} updateId
1 */
export async function deleteUpdate(courseId, updateId) {
const { data } = await getAuthenticatedHttpClient()
.delete(updateCourseUpdatesApiUrl(courseId, updateId));
return data;
}
/**
* Get course handouts.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseHandouts(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseHandoutApiUrl(courseId));
return data;
}
/**
* Edit course handouts.
* @param {string} courseId
* @param {object} courseHandouts
* @returns {Promise<Object>}
*/
export async function editHandouts(courseId, courseHandouts) {
const { data } = await getAuthenticatedHttpClient()
.put(getCourseHandoutApiUrl(courseId), courseHandouts);
return data;
}

View File

@@ -0,0 +1,4 @@
export const getCourseUpdates = (state) => state.courseUpdates.courseUpdates;
export const getCourseHandouts = (state) => state.courseUpdates.courseHandouts;
export const getSavingStatuses = (state) => state.courseUpdates.savingStatuses;
export const getLoadingStatuses = (state) => state.courseUpdates.loadingStatuses;

View File

@@ -0,0 +1,72 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { sortBy } from 'lodash';
const initialState = {
courseUpdates: [],
courseHandouts: {},
savingStatuses: {
createCourseUpdateQuery: '',
editCourseUpdateQuery: '',
deleteCourseUpdateQuery: '',
editCourseHandoutsQuery: '',
},
loadingStatuses: {
fetchCourseUpdatesQuery: '',
fetchCourseHandoutsQuery: '',
},
};
const slice = createSlice({
name: 'courseUpdates',
initialState,
reducers: {
fetchCourseUpdatesSuccess: (state, { payload }) => {
state.courseUpdates = payload;
},
createCourseUpdate: (state, { payload }) => {
state.courseUpdates = [payload, ...state.courseUpdates];
},
editCourseUpdate: (state, { payload }) => {
state.courseUpdates = state.courseUpdates.map((courseUpdate) => {
if (courseUpdate.id === payload.id) {
return payload;
}
return courseUpdate;
});
},
deleteCourseUpdate: (state, { payload }) => {
state.courseUpdates = sortBy(payload, 'id').reverse();
},
fetchCourseHandoutsSuccess: (state, { payload }) => {
state.courseHandouts = payload;
},
editCourseHandouts: (state, { payload }) => {
state.courseHandouts = {
...state.courseHandouts,
...payload,
};
},
updateSavingStatuses: (state, { payload }) => {
state.savingStatuses = { ...state.savingStatuses, ...payload };
},
updateLoadingStatuses: (state, { payload }) => {
state.loadingStatuses = { ...state.loadingStatuses, ...payload };
},
},
});
export const {
fetchCourseUpdatesSuccess,
createCourseUpdate,
editCourseUpdate,
deleteCourseUpdate,
fetchCourseHandoutsSuccess,
editCourseHandouts,
updateSavingStatuses,
updateLoadingStatuses,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,111 @@
import { NOTIFICATION_MESSAGES } from '../../constants';
import { RequestStatus } from '../../data/constants';
import { hideProcessingNotification, showProcessingNotification } from '../../generic/processing-notification/data/slice';
import {
getCourseUpdates,
getCourseHandouts,
createUpdate,
editUpdate,
deleteUpdate,
editHandouts,
} from './api';
import {
fetchCourseUpdatesSuccess,
createCourseUpdate,
editCourseUpdate,
deleteCourseUpdate,
fetchCourseHandoutsSuccess,
editCourseHandouts,
updateLoadingStatuses,
updateSavingStatuses,
} from './slice';
export function fetchCourseUpdatesQuery(courseId) {
return async (dispatch) => {
try {
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.IN_PROGRESS }));
const courseUpdates = await getCourseUpdates(courseId);
dispatch(fetchCourseUpdatesSuccess(courseUpdates));
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.FAILED }));
}
};
}
export function createCourseUpdateQuery(courseId, data) {
return async (dispatch) => {
try {
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
const courseUpdate = await createUpdate(courseId, data);
dispatch(createCourseUpdate(courseUpdate));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
}
};
}
export function editCourseUpdateQuery(courseId, data) {
return async (dispatch) => {
try {
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
const courseUpdate = await editUpdate(courseId, data);
dispatch(editCourseUpdate(courseUpdate));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
}
};
}
export function deleteCourseUpdateQuery(courseId, updateId) {
return async (dispatch) => {
try {
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
const courseUpdates = await deleteUpdate(courseId, updateId);
dispatch(deleteCourseUpdate(courseUpdates));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
}
};
}
export function fetchCourseHandoutsQuery(courseId) {
return async (dispatch) => {
try {
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.IN_PROGRESS }));
const courseHandouts = await getCourseHandouts(courseId);
dispatch(fetchCourseHandoutsSuccess(courseHandouts));
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLoadingStatuses({ fetchCourseHandoutsQuery: RequestStatus.FAILED }));
}
};
}
export function editCourseHandoutsQuery(courseId, data) {
return async (dispatch) => {
try {
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
const courseHandouts = await editHandouts(courseId, data);
dispatch(editCourseHandouts(courseHandouts));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.FAILED }));
}
};
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ActionRow,
Button,
AlertModal,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
const intl = useIntl();
return (
<AlertModal
title={intl.formatMessage(messages.deleteModalTitle)}
variant="warning"
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={close}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button
onClick={(e) => {
e.preventDefault();
onDeleteSubmit();
}}
>
{intl.formatMessage(messages.okButton)}
</Button>
</ActionRow>
)}
>
<p>{intl.formatMessage(messages.deleteModalDescription)}</p>
</AlertModal>
);
};
DeleteModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
onDeleteSubmit: PropTypes.func.isRequired,
};
export default DeleteModal;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import DeleteModal from './DeleteModal';
import messages from './messages';
const onDeleteSubmitMock = jest.fn();
const closeMock = jest.fn();
const renderComponent = (props) => render(
<IntlProvider locale="en">
<DeleteModal
isOpen
close={closeMock}
onDeleteSubmit={onDeleteSubmitMock}
{...props}
/>
</IntlProvider>,
);
describe('<DeleteModal />', () => {
it('render DeleteModal component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(messages.deleteModalTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.deleteModalDescription.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.okButton.defaultMessage })).toBeInTheDocument();
});
it('calls onDeleteSubmit function when the "Ok" button is clicked', () => {
const { getByRole } = renderComponent();
const okButton = getByRole('button', { name: messages.okButton.defaultMessage });
fireEvent.click(okButton);
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
});
it('calls the close function when the "Cancel" button is clicked', () => {
const { getByRole } = renderComponent();
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
expect(closeMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
deleteModalTitle: {
id: 'course-authoring.course-updates.delete-modal.title',
defaultMessage: 'Are you sure you want to delete this update?',
},
deleteModalDescription: {
id: 'course-authoring.course-updates.delete-modal.description',
defaultMessage: 'This action cannot be undone.',
},
cancelButton: {
id: 'course-authoring.course-updates.actions.cancel',
defaultMessage: 'Cancel',
},
okButton: {
id: 'course-authoring.course-updates.button.ok',
defaultMessage: 'Ok',
},
});
export default messages;

View File

@@ -0,0 +1,114 @@
import { useDispatch, useSelector } from 'react-redux';
import moment from 'moment/moment';
import { useEffect, useState } from 'react';
import { useToggle } from '@edx/paragon';
import { COMMA_SEPARATED_DATE_FORMAT } from '../constants';
import { getCourseHandouts, getCourseUpdates } from './data/selectors';
import { REQUEST_TYPES } from './constants';
import {
createCourseUpdateQuery,
deleteCourseUpdateQuery,
editCourseHandoutsQuery,
editCourseUpdateQuery,
fetchCourseHandoutsQuery,
fetchCourseUpdatesQuery,
} from './data/thunk';
const useCourseUpdates = ({ courseId }) => {
const dispatch = useDispatch();
const initialUpdate = { id: 0, date: moment().utc().toDate(), content: '' };
const [requestType, setRequestType] = useState('');
const [isUpdateFormOpen, openUpdateForm, closeUpdateForm] = useToggle(false);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [currentUpdate, setCurrentUpdate] = useState(initialUpdate);
const courseUpdates = useSelector(getCourseUpdates);
const courseHandouts = useSelector(getCourseHandouts);
const courseUpdatesInitialValues = requestType === REQUEST_TYPES.edit_handouts
? courseHandouts
: currentUpdate;
const handleOpenUpdateForm = (type, courseUpdate) => {
setRequestType(type);
switch (type) {
case REQUEST_TYPES.add_new_update:
setCurrentUpdate(initialUpdate);
break;
case REQUEST_TYPES.edit_update:
setCurrentUpdate(courseUpdate);
break;
default:
window.scrollTo(0, 0);
}
openUpdateForm();
};
const handleOpenDeleteForm = (courseUpdate) => {
setRequestType(REQUEST_TYPES.delete_update);
setCurrentUpdate(courseUpdate);
openDeleteModal();
};
const handleUpdatesSubmit = (data) => {
const dataToSend = {
...data,
date: moment(data.date).format(COMMA_SEPARATED_DATE_FORMAT),
};
const { id, date, content } = dataToSend;
const handleQuerySubmit = (handler) => {
closeUpdateForm();
setCurrentUpdate(initialUpdate);
return handler();
};
switch (requestType) {
case REQUEST_TYPES.add_new_update:
return handleQuerySubmit(dispatch(createCourseUpdateQuery(courseId, { date, content })));
case REQUEST_TYPES.edit_update:
return handleQuerySubmit(dispatch(editCourseUpdateQuery(courseId, { id, date, content })));
case REQUEST_TYPES.edit_handouts:
return handleQuerySubmit(dispatch(editCourseHandoutsQuery(courseId, { ...data, data: data?.data || '' })));
default:
return true;
}
};
const handleDeleteUpdateSubmit = () => {
const { id } = currentUpdate;
dispatch(deleteCourseUpdateQuery(courseId, id));
setCurrentUpdate(initialUpdate);
closeDeleteModal();
};
useEffect(() => {
dispatch(fetchCourseUpdatesQuery(courseId));
dispatch(fetchCourseHandoutsQuery(courseId));
}, [courseId]);
return {
requestType,
courseUpdates,
courseHandouts,
courseUpdatesInitialValues,
isMainFormOpen: isUpdateFormOpen && requestType !== REQUEST_TYPES.edit_update,
isInnerFormOpen: (id) => isUpdateFormOpen && currentUpdate.id === id && requestType === REQUEST_TYPES.edit_update,
isUpdateFormOpen,
isDeleteModalOpen,
closeUpdateForm,
closeDeleteModal,
handleUpdatesSubmit,
handleOpenUpdateForm,
handleDeleteUpdateSubmit,
handleOpenDeleteForm,
};
};
// eslint-disable-next-line import/prefer-default-export
export { useCourseUpdates };

View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as CourseUpdates } from './CourseUpdates';

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
headingTitle: {
id: 'course-authoring.course-updates.header.title',
defaultMessage: 'Course updates',
},
headingSubtitle: {
id: 'course-authoring.course-updates.header.subtitle',
defaultMessage: 'Content',
},
sectionInfo: {
id: 'course-authoring.course-updates.section-info',
defaultMessage: 'Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.',
},
newUpdateButton: {
id: 'course-authoring.course-updates.actions.new-update',
defaultMessage: 'New update',
},
});
export default messages;

View File

@@ -0,0 +1,141 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ActionRow,
Button,
Form,
Icon,
} from '@edx/paragon';
import classNames from 'classnames';
import DatePicker from 'react-datepicker/dist';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Calendar as CalendarIcon, Error as ErrorIcon } from '@edx/paragon/icons';
import { Formik } from 'formik';
import {
convertToStringFromDate,
convertToDateFromString,
isValidDate,
} from '../../utils';
import { DATE_FORMAT, DEFAULT_EMPTY_WYSIWYG_VALUE } from '../../constants';
import { WysiwygEditor } from '../../generic/WysiwygEditor';
import { REQUEST_TYPES } from '../constants';
import { geUpdateFormSettings } from './utils';
import messages from './messages';
const UpdateForm = ({
close,
requestType,
onSubmit,
courseUpdatesInitialValues,
isInnerForm,
isFirstUpdate,
}) => {
const intl = useIntl();
const {
currentContent,
formTitle,
validationSchema,
contentFieldName,
submitButtonText,
} = geUpdateFormSettings(requestType, courseUpdatesInitialValues, intl);
return (
<div className={classNames('update-form', {
'update-form__inner': isInnerForm,
'update-form__inner-first': isFirstUpdate,
})}
>
<Formik
initialValues={courseUpdatesInitialValues}
validationSchema={validationSchema}
validateOnMount
validateOnBlur
onSubmit={onSubmit}
>
{({
values, handleSubmit, isValid, setFieldValue,
}) => (
<>
<h3 className="update-form-title">{formTitle}</h3>
{(requestType !== REQUEST_TYPES.edit_handouts) && (
<Form.Group className="mb-4 datepicker-field datepicker-custom">
<Form.Control.Feedback className="datepicker-float-labels">
{intl.formatMessage(messages.updateFormDate)}
</Form.Control.Feedback>
<div className="position-relative">
<Icon
src={CalendarIcon}
className="datepicker-custom-control-icon"
alt={intl.formatMessage(messages.updateFormCalendarAltText)}
/>
<DatePicker
name="date"
data-testid="course-updates-datepicker"
selected={isValidDate(values.date) ? convertToDateFromString(values.date) : ''}
dateFormat={DATE_FORMAT}
className={classNames('datepicker-custom-control', {
'datepicker-custom-control_isInvalid': !isValid,
})}
autoComplete="off"
selectsStart
showPopperArrow={false}
onChange={(value) => {
if (!isValidDate(value)) {
return;
}
setFieldValue('date', convertToStringFromDate(value));
}}
/>
</div>
{!isValid && (
<div className="datepicker-field-error">
<Icon src={ErrorIcon} alt={intl.formatMessage(messages.updateFormErrorAltText)} />
<span className="message-error">{intl.formatMessage(messages.updateFormInValid)}</span>
</div>
)}
</Form.Group>
)}
<Form.Group className="m-0 mb-3">
<WysiwygEditor
initialValue={currentContent}
data-testid="course-updates-wisiwyg-editor"
name={contentFieldName}
minHeight={300}
onChange={(value) => {
setFieldValue(contentFieldName, value || DEFAULT_EMPTY_WYSIWYG_VALUE);
}}
/>
</Form.Group>
<ActionRow>
<Button variant="tertiary" type="button" onClick={close}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button onClick={handleSubmit} type="submit" disabled={!isValid}>
{submitButtonText}
</Button>
</ActionRow>
</>
)}
</Formik>
</div>
);
};
UpdateForm.defaultProps = {
isInnerForm: false,
isFirstUpdate: false,
};
UpdateForm.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
courseUpdatesInitialValues: PropTypes.object.isRequired,
close: PropTypes.func.isRequired,
requestType: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
isInnerForm: PropTypes.bool,
isFirstUpdate: PropTypes.bool,
};
export default UpdateForm;

View File

@@ -0,0 +1,63 @@
.update-form {
@include pgn-box-shadow(1, "centered");
border: .0625rem solid $gray-200;
border-radius: .375rem;
background: $white;
margin-bottom: map-get($spacers, 4);
padding: $spacer 1.875rem;
.update-form-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: $spacer;
}
.datepicker-field {
display: flex;
align-items: center;
gap: .5rem;
position: relative;
.datepicker-float-labels {
position: absolute;
padding: 0 .1875rem;
top: -.625rem;
left: .3125rem;
z-index: 9;
background-color: $white;
}
.datepicker-field-error {
display: flex;
align-items: center;
gap: .25rem;
svg {
color: $warning-300;
}
}
.react-datepicker-popper {
z-index: $zindex-dropdown;
}
}
}
.update-form__inner {
margin-bottom: 0;
margin-top: 1.875rem;
padding: map-get($spacers, 4) 0 0;
border-top: .0625rem solid $light-400;
border-bottom: none;
border-left: none;
border-right: none;
border-radius: 0;
box-shadow: none;
}
.update-form__inner-first {
border-top: none;
padding-top: 0;
margin-top: 0;
}

View File

@@ -0,0 +1,140 @@
import React from 'react';
import {
render,
fireEvent,
waitFor,
act,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import moment from 'moment/moment';
import { REQUEST_TYPES } from '../constants';
import { courseHandoutsMock, courseUpdatesMock } from '../__mocks__';
import UpdateForm from './UpdateForm';
import messages from './messages';
const closeMock = jest.fn();
const onSubmitMock = jest.fn();
const addNewUpdateMock = { id: 0, date: moment().utc().toDate(), content: 'Some content' };
const formattedDateMock = '07/11/2023';
const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
jest.mock('@tinymce/tinymce-react', () => {
const originalModule = jest.requireActual('@tinymce/tinymce-react');
return {
__esModule: true,
...originalModule,
Editor: () => 'foo bar',
};
});
jest.mock('@edx/frontend-lib-content-components', () => ({
TinyMceWidget: () => <div>Widget</div>,
prepareEditorRef: jest.fn(() => ({
refReady: true,
setEditorRef: jest.fn().mockName('prepareEditorRef.setEditorRef'),
})),
}));
const courseUpdatesInitialValues = (requestType) => {
switch (requestType) {
case REQUEST_TYPES.add_new_update:
return addNewUpdateMock;
case REQUEST_TYPES.edit_update:
return courseUpdatesMock[0];
default:
return courseHandoutsMock;
}
};
const renderComponent = ({ requestType }) => render(
<IntlProvider locale="en">
<UpdateForm
isOpen
close={closeMock}
requestType={requestType}
onSubmit={onSubmitMock}
courseUpdatesInitialValues={courseUpdatesInitialValues(requestType)}
/>
</IntlProvider>,
);
describe('<UpdateForm />', () => {
it('render Add new update form correctly', async () => {
const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update });
const { date } = courseUpdatesInitialValues(REQUEST_TYPES.add_new_update);
const formattedDate = moment(date).utc().format('MM/DD/yyyy');
expect(getByText(messages.addNewUpdateTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.updateFormDate.defaultMessage)).toBeInTheDocument();
expect(getByDisplayValue(formattedDate)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage }));
expect(getByRole('button', { name: messages.postButton.defaultMessage }));
});
it('render Edit update form correctly', async () => {
const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.edit_update });
expect(getByText(messages.editUpdateTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.updateFormDate.defaultMessage)).toBeInTheDocument();
expect(getByDisplayValue(formattedDateMock)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage }));
expect(getByRole('button', { name: messages.postButton.defaultMessage }));
});
it('render Edit handouts form correctly', async () => {
const {
getByText, getByRole, queryByTestId, queryByText,
} = renderComponent({ requestType: REQUEST_TYPES.edit_handouts });
expect(getByText(messages.editHandoutsTitle.defaultMessage)).toBeInTheDocument();
expect(queryByText(messages.updateFormDate.defaultMessage)).not.toBeInTheDocument();
expect(queryByTestId('course-updates-datepicker')).not.toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage }));
expect(getByRole('button', { name: messages.saveButton.defaultMessage }));
});
it('calls closeMock when clicking cancel button', () => {
const { getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update });
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
expect(closeMock).toHaveBeenCalledTimes(1);
});
it('calls onSubmitMock with correct values when clicking post button', async () => {
const { getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.edit_update });
const datePicker = getByDisplayValue(formattedDateMock);
const postButton = getByRole('button', { name: messages.postButton.defaultMessage });
fireEvent.change(datePicker, { target: { value: formattedDateMock } });
await act(async () => {
fireEvent.click(postButton);
});
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
expect(onSubmitMock).toHaveBeenCalledWith(
{
id: 1,
date: 'July 11, 2023',
content: contentMock,
},
expect.objectContaining({ submitForm: expect.any(Function) }),
);
});
});
it('render error message when date is inValid', async () => {
const { getByDisplayValue, getByText, getByRole } = renderComponent({ requestType: REQUEST_TYPES.edit_update });
const datePicker = getByDisplayValue(formattedDateMock);
fireEvent.change(datePicker, { target: { value: '' } });
await waitFor(() => {
expect(getByText(messages.updateFormInValid.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.postButton.defaultMessage })).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,46 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
updateFormDate: {
id: 'course-authoring.course-updates.update-form.date',
defaultMessage: 'Date',
},
updateFormInValid: {
id: 'course-authoring.course-updates.update-form.inValid',
defaultMessage: 'Action required: Enter a valid date.',
},
updateFormCalendarAltText: {
id: 'course-authoring.course-updates.update-form.calendar-alt-text',
defaultMessage: 'Calendar for datepicker input',
},
updateFormErrorAltText: {
id: 'course-authoring.course-updates.update-form.error-alt-text',
defaultMessage: 'Error icon',
},
addNewUpdateTitle: {
id: 'course-authoring.course-updates.update-form.new-update-title',
defaultMessage: 'Add new update',
},
editUpdateTitle: {
id: 'course-authoring.course-updates.update-form.edit-update-title',
defaultMessage: 'Edit update',
},
editHandoutsTitle: {
id: 'course-authoring.course-updates.update-form.edit-handouts-title',
defaultMessage: 'Edit handouts',
},
saveButton: {
id: 'course-authoring.course-updates.actions.save',
defaultMessage: 'Save',
},
postButton: {
id: 'course-authoring.course-updates.actions.post',
defaultMessage: 'Post',
},
cancelButton: {
id: 'course-authoring.course-updates.actions.cancel',
defaultMessage: 'Cancel',
},
});
export default messages;

View File

@@ -0,0 +1,56 @@
import * as Yup from 'yup';
import { REQUEST_TYPES } from '../constants';
import messages from './messages';
/**
* Get Update form settings depending on requestType
* @param {typeof REQUEST_TYPES} requestType - one of REQUEST_TYPES
* @param {object} courseUpdatesInitialValues - form initial values depending on requestType
* @returns {{
* currentContent: string,
* validationSchema: object,
* formTitle: string,
* submitButtonText: string,
* contentFieldName: string
* }}
*/
const geUpdateFormSettings = (requestType, courseUpdatesInitialValues, intl) => {
const updatesValidationSchema = Yup.object().shape({
id: Yup.number().required(),
date: Yup.date().required(),
content: Yup.string(),
});
switch (requestType) {
case REQUEST_TYPES.edit_handouts:
return {
currentContent: courseUpdatesInitialValues.data,
formTitle: intl.formatMessage(messages.editHandoutsTitle),
validationSchema: Yup.object().shape(),
contentFieldName: 'data',
submitButtonText: intl.formatMessage(messages.saveButton),
};
case REQUEST_TYPES.add_new_update:
return {
currentContent: courseUpdatesInitialValues.content,
formTitle: intl.formatMessage(messages.addNewUpdateTitle),
validationSchema: updatesValidationSchema,
contentFieldName: 'content',
submitButtonText: intl.formatMessage(messages.postButton),
};
case REQUEST_TYPES.edit_update:
return {
currentContent: courseUpdatesInitialValues.content,
formTitle: intl.formatMessage(messages.editUpdateTitle),
validationSchema: updatesValidationSchema,
contentFieldName: 'content',
submitButtonText: intl.formatMessage(messages.postButton),
};
default:
return '';
}
};
// eslint-disable-next-line import/prefer-default-export
export { geUpdateFormSettings };

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const matchesAnyStatus = (statuses, status) => Object.values(statuses).some(s => s === status);

View File

@@ -0,0 +1,5 @@
.tox-dialog-wrap__backdrop {
background-color: $black !important;
opacity: .5;
z-index: $zindex-modal-backdrop;
}

View File

@@ -0,0 +1,25 @@
.processing-notification {
display: flex;
position: fixed;
bottom: -13rem;
transition: bottom 1s;
right: 1.25rem;
padding: .625rem 1.25rem;
z-index: $zindex-popover;
&.is-show {
bottom: .625rem;
}
.processing-notification-icon {
margin-right: .625rem;
animation: rotate 1s linear infinite;
}
.processing-notification-title {
font-size: 1rem;
line-height: 1.5rem;
color: $white;
margin-bottom: 0;
}
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { render } from '@testing-library/react';
import { capitalize } from 'lodash';
import { NOTIFICATION_MESSAGES } from '../../constants';
import ProcessingNotification from '.';
const props = {
title: NOTIFICATION_MESSAGES.saving,
isShow: true,
};
describe('<ProcessingNotification />', () => {
it('renders successfully', () => {
const { getByText } = render(<ProcessingNotification {...props} />);
expect(getByText(capitalize(props.title))).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,5 @@
// eslint-disable-next-line import/prefer-default-export
export const getProcessingNotification = (state) => ({
isShow: state.processingNotification.isShow,
title: state.processingNotification.title,
});

View File

@@ -0,0 +1,28 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isShow: false,
title: '',
};
const slice = createSlice({
name: 'processingNotification',
initialState,
reducers: {
showProcessingNotification: (state, { payload }) => {
state.isShow = true;
state.title = payload;
},
hideProcessingNotification: () => initialState,
},
});
export const {
showProcessingNotification,
hideProcessingNotification,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Badge, Icon } from '@edx/paragon';
import { Settings as IconSettings } from '@edx/paragon/icons';
import { capitalize } from 'lodash';
import { NOTIFICATION_MESSAGES } from '../../constants';
const ProcessingNotification = ({ isShow, title }) => (
<Badge
className={classNames('processing-notification', {
'is-show': isShow,
})}
variant="secondary"
aria-hidden={isShow}
>
<Icon className="processing-notification-icon" src={IconSettings} />
<h2 className="processing-notification-title">
{capitalize(title)}
</h2>
</Badge>
);
ProcessingNotification.propTypes = {
isShow: PropTypes.bool.isRequired,
title: PropTypes.oneOf(Object.values(NOTIFICATION_MESSAGES)).isRequired,
};
export default ProcessingNotification;

View File

@@ -2,3 +2,5 @@
@import "./course-upload-image/CourseUploadImage";
@import "./sub-header/SubHeader";
@import "./section-sub-header/SectionSubHeader";
@import "./processing-notification/ProccessingNotification";
@import "./WysiwygEditor";

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -642,6 +642,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -643,6 +643,28 @@
"course-authoring.grading-settings.assignment.type-name.error.message-2": "For grading to work, you must change all {initialAssignmentName} subsections to {value}",
"course-authoring.schedule.alert.button.saving": "Saving",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-form.new-update-title": "Add new update",
"course-authoring.course-updates.update-form.edit-update-title": "Edit update",
"course-authoring.course-updates.update-form.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-form.date": "Date",
"course-authoring.course-updates.update-form.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-form.error-alt-text": "Error icon",
"course-authoring.course-updates.update-form.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-team.headingTitle": "Course team",
"course-authoring.course-team.subTitle": "Settings",
"course-authoring.course-team.button.new-team-member": "New team member",

View File

@@ -678,6 +678,28 @@
"course-authoring.course-team.warning-modal.message": "{email} is already on the {courseName} team. Recheck the email address if you want to add a new member.",
"course-authoring.course-team.warning-modal.button.return": "Return to team listing",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.course-updates.header.title": "Course updates",
"course-authoring.course-updates.header.subtitle": "Content",
"course-authoring.course-updates.section-info": "Use course updates to notify students of important dates or exams, highlight particular discussions in the forums, announce schedule changes, and respond to student questions. You add or edit updates in HTML.",
"course-authoring.course-updates.handouts.title": "Course handouts",
"course-authoring.course-updates.button.new-update": "New update",
"course-authoring.course-updates.button.edit": "Edit",
"course-authoring.course-updates.button.delete": "Delete",
"course-authoring.course-updates.button.save": "Save",
"course-authoring.course-updates.button.cancel": "Cancel",
"course-authoring.course-updates.button.post": "Post",
"course-authoring.course-updates.button.ok": "Ok",
"course-authoring.course-updates.date-invalid": "Action required: Enter a valid date.",
"course-authoring.course-updates.delete-modal.title": "Are you sure you want to delete this update?",
"course-authoring.course-updates.delete-modal.description": "This action cannot be undone.",
"course-authoring.course-updates.update-modal.new-update-title": "Add new update",
"course-authoring.course-updates.update-modal.edit-update-title": "Edit update",
"course-authoring.course-updates.update-modal.edit-handouts-title": "Edit handouts",
"course-authoring.course-updates.update-modal.date": "Date",
"course-authoring.course-updates.update-modal.inValid": "Action required: Enter a valid date.",
"course-authoring.course-updates.update-modal.error-alt-text": "Error icon",
"course-authoring.course-updates.update-modal.calendar-alt-text": "Calendar for datepicker input",
"course-authoring.advanced-settings.alert.button.saving": "Saving",
"course-authoring.grading-settings.credit.eligibility.label": "Minimum credit-eligible grade:",
"course-authoring.grading-settings.credit.eligibility.description": "% Must be greater than or equal to the course passing grade",
"course-authoring.grading-settings.credit.eligibility.error.msg": "Not able to set passing grade to less than:",

View File

@@ -7,6 +7,7 @@
@import "assets/scss/variables";
@import "assets/scss/form";
@import "assets/scss/utilities";
@import "assets/scss/animations";
@import "proctored-exam-settings/proctoredExamSettings";
@import "pages-and-resources/discussions/app-list/AppList";
@import "advanced-settings/scss/AdvancedSettings";
@@ -15,3 +16,4 @@
@import "schedule-and-details/ScheduleAndDetails";
@import "pages-and-resources/PagesAndResources";
@import "course-team/CourseTeam";
@import "course-updates/CourseUpdates";

View File

@@ -11,6 +11,8 @@ import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/dat
import { reducer as liveReducer } from './pages-and-resources/live/data/slice';
import { reducer as filesReducer } from './files-and-uploads/data/slice';
import { reducer as courseTeamReducer } from './course-team/data/slice';
import { reducer as CourseUpdatesReducer } from './course-updates/data/slice';
import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice';
export default function initializeStore(preloadedState = undefined) {
return configureStore({
@@ -26,6 +28,8 @@ export default function initializeStore(preloadedState = undefined) {
models: modelsReducer,
live: liveReducer,
courseTeam: courseTeamReducer,
courseUpdates: CourseUpdatesReducer,
processingNotification: processingNotificationReducer,
},
preloadedState,
});