diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index f72ec3bdba..c3e51c9535 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -57,6 +57,7 @@ const CourseOutline = ({ courseId }) => { handlePublishSectionSubmit, handleEditSectionSubmit, handleDeleteSectionSubmit, + handleDuplicateSectionSubmit, } = useCourseOutline({ courseId }); if (isLoading) { @@ -123,6 +124,7 @@ const CourseOutline = ({ courseId }) => { onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} onEditSectionSubmit={handleEditSectionSubmit} + onDuplicateSubmit={handleDuplicateSectionSubmit} // TODO add handler in Add new subsection feature onClickNewSubsection={() => ({})} /> diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 7475b53947..766cc3854c 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -14,11 +14,13 @@ import { getCourseLaunchApiUrl, getCourseOutlineIndexApiUrl, getCourseReindexApiUrl, + getCourseSectionDuplicateApiUrl, getEnableHighlightsEmailsApiUrl, getUpdateCourseSectionApiUrl, } from './data/api'; import { deleteCourseSectionQuery, + duplicateCourseSectionQuery, editCourseSectionQuery, enableCourseHighlightsEmailsQuery, fetchCourseBestPracticesQuery, @@ -208,4 +210,26 @@ describe('', () => { expect(queryByText(section.displayName)).not.toBeInTheDocument(); }); }); + + it('check duplicate section when duplicate query is successfully', async () => { + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(200, courseOutlineIndexMock); + + const { getAllByTestId } = render(); + const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; + const courseBlockId = courseOutlineIndexMock.courseStructure.id; + + axiosMock + .onPost(getCourseSectionDuplicateApiUrl()) + .reply(200, { + duplicate_source_locator: section.id, + parent_locator: courseBlockId, + }); + await executeThunk(duplicateCourseSectionQuery(section.id, courseBlockId), store.dispatch); + + await waitFor(() => { + expect(getAllByTestId('section-card')).toHaveLength(4); + }); + }); }); diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 79a6b9e14b..b8a61a44e1 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -36,6 +36,7 @@ const CardHeader = ({ closeForm, isDisabledEditField, onClickDelete, + onClickDuplicate, }) => { const intl = useIntl(); const [titleValue, setTitleValue] = useState(title); @@ -136,7 +137,7 @@ const CardHeader = ({ {intl.formatMessage(messages.menuPublish)} {intl.formatMessage(messages.menuConfigure)} - {intl.formatMessage(messages.menuDuplicate)} + {intl.formatMessage(messages.menuDuplicate)} {intl.formatMessage(messages.menuDelete)} @@ -158,6 +159,7 @@ CardHeader.propTypes = { closeForm: PropTypes.func.isRequired, isDisabledEditField: PropTypes.bool.isRequired, onClickDelete: PropTypes.func.isRequired, + onClickDuplicate: PropTypes.func.isRequired, }; export default CardHeader; diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.jsx index 253517da03..f723378cd4 100644 --- a/src/course-outline/card-header/CardHeader.test.jsx +++ b/src/course-outline/card-header/CardHeader.test.jsx @@ -11,6 +11,7 @@ const onClickMenuButtonMock = jest.fn(); const onClickPublishMock = jest.fn(); const onClickEditMock = jest.fn(); const onClickDeleteMock = jest.fn(); +const onClickDuplicateMock = jest.fn(); const cardHeaderProps = { title: 'Some title', @@ -25,6 +26,7 @@ const cardHeaderProps = { closeForm: jest.fn(), isDisabledEditField: false, onClickDelete: onClickDeleteMock, + onClickDuplicate: onClickDuplicateMock, }; const renderComponent = (props) => render( @@ -166,4 +168,15 @@ describe('', () => { fireEvent.click(deleteMenuItem); expect(onClickDeleteMock).toHaveBeenCalledTimes(1); }); + + it('calls onClickDuplicate when item is clicked', () => { + const { getByText, getByTestId } = renderComponent(); + + const menuButton = getByTestId('section-card-header__menu-button'); + fireEvent.click(menuButton); + + const duplicateMenuItem = getByText(messages.menuDuplicate.defaultMessage); + fireEvent.click(duplicateMenuItem); + expect(onClickDuplicateMock).toHaveBeenCalled(); + }); }); diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 55bf956a45..aea962b135 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -25,6 +25,9 @@ export const getEnableHighlightsEmailsApiUrl = (courseId) => { }; export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`; +export const getUpdateCourseSectionApiUrl = (sectionId) => `${getApiBaseUrl()}/xblock/${sectionId}`; +export const getCourseSectionApiUrl = (sectionId) => `${getApiBaseUrl()}/xblock/outline/${sectionId}`; +export const getCourseSectionDuplicateApiUrl = () => `${getApiBaseUrl()}/xblock/`; /** * Get course outline index. @@ -148,3 +151,19 @@ export async function deleteCourseSection(sectionId) { return data; } + +/** + * Duplicate course section + * @param {string} sectionId + * @param {string} courseBlockId + * @returns {Promise} + */ +export async function duplicateCourseSection(sectionId, courseBlockId) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseSectionDuplicateApiUrl(), { + duplicate_source_locator: sectionId, + parent_locator: courseBlockId, + }); + + return data; +} diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 9f2c0f18f9..036e8ae3b6 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -70,6 +70,14 @@ const slice = createSlice({ deleteSection: (state, { payload }) => { state.sectionsList = state.sectionsList.filter(({ id }) => id !== payload); }, + duplicateSection: (state, { payload }) => { + state.sectionsList = state.sectionsList.reduce((result, currentValue) => { + if (currentValue.id === payload.id) { + return [...result, currentValue, payload.duplicatedSection]; + } + return [...result, currentValue]; + }, []); + }, }, }); @@ -84,6 +92,7 @@ export const { updateSectionList, setCurrentSection, deleteSection, + duplicateSection, } = slice.actions; export const { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 32136cb306..dde828cbb7 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -12,6 +12,7 @@ import { } from '../utils/getChecklistForStatusBar'; import { deleteCourseSection, + duplicateCourseSection, editCourseSection, enableCourseHighlightsEmails, getCourseBestPractices, @@ -31,6 +32,7 @@ import { updateSectionList, updateFetchSectionLoadingStatus, deleteSection, + duplicateSection, } from './slice'; export function fetchCourseOutlineIndexQuery(courseId) { @@ -219,3 +221,27 @@ export function deleteCourseSectionQuery(sectionId) { } }; } + +export function duplicateCourseSectionQuery(sectionId, courseBlockId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await duplicateCourseSection(sectionId, courseBlockId).then(async (result) => { + if (result) { + const duplicatedSection = await getCourseSection(result.locator); + dispatch(duplicateSection({ id: sectionId, duplicatedSection })); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } + }); + + return true; + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index b85a41587a..a51b0735e4 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -15,6 +15,7 @@ import { import { // deleteCourseSectionQuery, editCourseSectionQuery, + duplicateCourseSectionQuery, enableCourseHighlightsEmailsQuery, fetchCourseBestPracticesQuery, fetchCourseLaunchQuery, @@ -82,6 +83,10 @@ const useCourseOutline = ({ courseId }) => { closeDeleteModal(); }; + const handleDuplicateSectionSubmit = () => { + dispatch(duplicateCourseSectionQuery(currentSection.id, courseStructure.id)); + }; + useEffect(() => { dispatch(fetchCourseOutlineIndexQuery(courseId)); dispatch(fetchCourseBestPracticesQuery({ courseId })); @@ -127,6 +132,7 @@ const useCourseOutline = ({ courseId }) => { closeDeleteModal, openDeleteModal, handleDeleteSectionSubmit, + handleDuplicateSectionSubmit, }; }; diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index b66cd1cc89..f0ac6d8ea1 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -17,6 +17,7 @@ const SectionCard = ({ onEditSectionSubmit, savingStatus, onOpenDeleteModal, + onDuplicateSubmit, }) => { const intl = useIntl(); const [isExpanded, setIsExpanded] = useState(true); @@ -63,7 +64,7 @@ const SectionCard = ({ }, [savingStatus]); return ( -
+
@@ -126,6 +128,7 @@ SectionCard.propTypes = { onEditSectionSubmit: PropTypes.func.isRequired, savingStatus: PropTypes.string.isRequired, onOpenDeleteModal: PropTypes.func.isRequired, + onDuplicateSubmit: PropTypes.func.isRequired, }; export default SectionCard; diff --git a/src/course-outline/section-card/SectionCard.test.jsx b/src/course-outline/section-card/SectionCard.test.jsx index ad9c0bc298..b02b11ea5d 100644 --- a/src/course-outline/section-card/SectionCard.test.jsx +++ b/src/course-outline/section-card/SectionCard.test.jsx @@ -39,6 +39,7 @@ const renderComponent = (props) => render( onEditClick={jest.fn()} savingStatus="" onEditSectionSubmit={jest.fn()} + onDuplicateSubmit={jest.fn()} {...props} > children