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

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