diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index bdeffa1108..0f1a470ebc 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -73,6 +73,7 @@ const CourseAuthoringRoutes = () => { /> {DECODED_ROUTES.COURSE_UNIT.map((path) => ( } /> diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 4020172048..ac3e8a21ad 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -27,14 +27,16 @@ const CourseUnit = ({ courseId }) => { isLoading, sequenceId, unitTitle, + isQueryPending, savingStatus, isTitleEditFormOpen, + isErrorAlert, isInternetConnectionAlertFailed, handleTitleEditSubmit, headerNavigationsActions, handleTitleEdit, handleInternetConnectionFailed, - handleCreateNewCourseXblock, + handleCreateNewCourseXBlock, } = useCourseUnit({ courseId, blockId }); document.title = getPageHeadTitle('', unitTitle); @@ -52,7 +54,7 @@ const CourseUnit = ({ courseId }) => { <>
- + {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} { courseId={courseId} sequenceId={sequenceId} unitId={blockId} + handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} /> { @@ -101,11 +104,13 @@ const CourseUnit = ({ courseId }) => { isShow={isShowProcessingNotification} title={processingNotificationTitle} /> - + {isQueryPending && ( + + )} ); diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 01a3199c93..15b94b4382 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -7,6 +7,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { cloneDeep, set } from 'lodash'; import { getCourseSectionVerticalApiUrl, @@ -23,11 +24,13 @@ import { courseCreateXblockMock, courseSectionVerticalMock, courseUnitIndexMock, + courseUnitMock, } from './__mocks__'; import { executeThunk } from '../utils'; import CourseUnit from './CourseUnit'; import headerNavigationsMessages from './header-navigations/messages'; import headerTitleMessages from './header-title/messages'; +import courseSequenceMessages from './course-sequence/messages'; import messages from './add-component/messages'; let axiosMock; @@ -192,6 +195,93 @@ describe('', () => { }); }); + it('correct addition of a new course unit after click on the "Add new unit" button', async () => { + const { getByRole, getAllByTestId } = render(); + let units = null; + const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); + const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; + set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ + ...updatedAncestorsChild.child_info.children, + courseUnitMock, + ]); + + await waitFor(async () => { + units = getAllByTestId('course-unit-btn'); + const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; + expect(units.length).toEqual(courseUnits.length); + }); + + axiosMock + .onPost(postXBlockBaseApiUrl(), { parent_locator: blockId, category: 'vertical', display_name: 'Unit' }) + .reply(200, { dummy: 'value' }); + axiosMock.reset(); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...updatedCourseSectionVerticalData, + }); + + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + const addNewUnitBtn = getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); + units = getAllByTestId('course-unit-btn'); + const updatedCourseUnits = updatedCourseSectionVerticalData + .xblock_info.ancestor_info.ancestors[0].child_info.children; + + userEvent.click(addNewUnitBtn); + expect(units.length).toEqual(updatedCourseUnits.length); + expect(mockedUsedNavigate).toHaveBeenCalled(); + expect(mockedUsedNavigate) + .toHaveBeenCalledWith(`/course/${courseId}/container/${blockId}/${updatedAncestorsChild.id}`, { replace: true }); + }); + + it('the sequence unit is updated after changing the unit header', async () => { + const { getAllByTestId, getByRole } = render(); + const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); + const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; + set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ + ...updatedAncestorsChild.child_info.children, + courseUnitMock, + ]); + + const newDisplayName = `${unitDisplayName} new`; + + axiosMock + .onPost(getXBlockBaseApiUrl(blockId, { + metadata: { + display_name: newDisplayName, + }, + })) + .reply(200, { dummy: 'value' }) + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + metadata: { + ...courseUnitIndexMock.metadata, + display_name: newDisplayName, + }, + }) + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...updatedCourseSectionVerticalData, + }); + + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + const editTitleButton = getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); + fireEvent.click(editTitleButton); + + const titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + fireEvent.change(titleEditField, { target: { value: newDisplayName } }); + + await act(async () => fireEvent.blur(titleEditField)); + + await waitFor(async () => { + const units = getAllByTestId('course-unit-btn'); + expect(units.some(unit => unit.title === newDisplayName)).toBe(true); + }); + }); + it('handles creating Video xblock and navigates to editor page', async () => { const { courseKey, locator } = courseCreateXblockMock; axiosMock diff --git a/src/course-unit/__mocks__/courseSectionVertical.js b/src/course-unit/__mocks__/courseSectionVertical.js index fdae7cdd56..ceea70fd55 100644 --- a/src/course-unit/__mocks__/courseSectionVertical.js +++ b/src/course-unit/__mocks__/courseSectionVertical.js @@ -498,6 +498,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'other', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9', @@ -581,6 +582,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'video', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0', @@ -664,6 +666,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'other', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76', @@ -747,6 +750,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'video', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0', @@ -830,6 +834,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'other', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1', @@ -913,6 +918,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'video', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606', @@ -996,6 +1002,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'other', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e', @@ -1079,6 +1086,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'other', }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193', @@ -1162,6 +1170,7 @@ module.exports = { selected_groups_label: '', }, enable_copy_paste_units: false, + xblock_type: 'video', }, ], }, diff --git a/src/course-unit/__mocks__/courseUnit.js b/src/course-unit/__mocks__/courseUnit.js new file mode 100644 index 0000000000..07c2bf03b1 --- /dev/null +++ b/src/course-unit/__mocks__/courseUnit.js @@ -0,0 +1,84 @@ +module.exports = { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2144', + display_name: 'Getting Started new', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + xblock_type: 'other', +}; diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js index a6f44a81d9..cd5c4ffdfe 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -1,3 +1,4 @@ export { default as courseUnitIndexMock } from './courseUnitIndex'; export { default as courseSectionVerticalMock } from './courseSectionVertical'; +export { default as courseUnitMock } from './courseUnit'; export { default as courseCreateXblockMock } from './courseCreateXblock'; diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index d00e3ca8d8..57622856b0 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -2,30 +2,58 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Button } from '@openedx/paragon'; +import { useToggle } from '@openedx/paragon'; import { getCourseSectionVertical } from '../data/selectors'; import { COMPONENT_ICON_TYPES } from '../constants'; -import ComponentIcon from './ComponentIcon'; +import ComponentModalView from './add-component-modals/ComponentModalView'; +import AddComponentButton from './add-component-btn'; import messages from './messages'; -const AddComponent = ({ blockId, handleCreateNewCourseXblock }) => { +const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { const navigate = useNavigate(); const intl = useIntl(); + const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false); + const [isOpenHtml, openHtml, closeHtml] = useToggle(false); + const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false); const { componentTemplates } = useSelector(getCourseSectionVertical); - const handleCreateNewXblock = (type) => () => { + const handleCreateNewXBlock = (type, moduleName) => { switch (type) { case COMPONENT_ICON_TYPES.discussion: case COMPONENT_ICON_TYPES.dragAndDrop: - handleCreateNewCourseXblock({ type, parentLocator: blockId }); + handleCreateNewCourseXBlock({ type, parentLocator: blockId }); break; case COMPONENT_ICON_TYPES.problem: case COMPONENT_ICON_TYPES.video: - handleCreateNewCourseXblock({ type, parentLocator: blockId }, ({ courseKey, locator }) => { + handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => { navigate(`/course/${courseKey}/editor/${type}/${locator}`); }); break; + // TODO: The library functional will be a bit different of current legacy (CMS) + // behaviour and this ticket is on hold (blocked by other development team). + case COMPONENT_ICON_TYPES.library: + handleCreateNewCourseXBlock({ type, category: 'library_content', parentLocator: blockId }); + break; + case COMPONENT_ICON_TYPES.advanced: + handleCreateNewCourseXBlock({ + type: moduleName, category: moduleName, parentLocator: blockId, + }); + break; + case COMPONENT_ICON_TYPES.openassessment: + handleCreateNewCourseXBlock({ + boilerplate: moduleName, category: type, parentLocator: blockId, + }); + break; + case COMPONENT_ICON_TYPES.html: + handleCreateNewCourseXBlock({ + type, + boilerplate: moduleName, + parentLocator: blockId, + }, ({ courseKey, locator }) => { + navigate(`/course/${courseKey}/editor/html/${locator}`); + }); + break; default: } }; @@ -38,19 +66,53 @@ const AddComponent = ({ blockId, handleCreateNewCourseXblock }) => {
{intl.formatMessage(messages.title)}
    - {Object.keys(componentTemplates).map((component) => ( -
  • - -
  • - ))} + {componentTemplates.map((component) => { + const { type, displayName } = component; + let modalParams; + + switch (type) { + case COMPONENT_ICON_TYPES.advanced: + modalParams = { + open: openAdvanced, + close: closeAdvanced, + isOpen: isOpenAdvanced, + }; + break; + case COMPONENT_ICON_TYPES.html: + modalParams = { + open: openHtml, + close: closeHtml, + isOpen: isOpenHtml, + }; + break; + case COMPONENT_ICON_TYPES.openassessment: + modalParams = { + open: openOpenAssessment, + close: closeOpenAssessment, + isOpen: isOpenOpenAssessment, + }; + break; + default: + return ( +
  • + handleCreateNewXBlock(type)} + displayName={displayName} + type={type} + /> +
  • + ); + } + + return ( + + ); + })}
); @@ -58,7 +120,7 @@ const AddComponent = ({ blockId, handleCreateNewCourseXblock }) => { AddComponent.propTypes = { blockId: PropTypes.string.isRequired, - handleCreateNewCourseXblock: PropTypes.func.isRequired, + handleCreateNewCourseXBlock: PropTypes.func.isRequired, }; export default AddComponent; diff --git a/src/course-unit/add-component/AddComponent.scss b/src/course-unit/add-component/AddComponent.scss index aba0a04e1c..afb97fafd0 100644 --- a/src/course-unit/add-component/AddComponent.scss +++ b/src/course-unit/add-component/AddComponent.scss @@ -10,3 +10,7 @@ height: 6.875rem; } } + +.add-component-modal-radio .pgn__form-radio-input { + min-width: 1.25rem; +} diff --git a/src/course-unit/add-component/AddComponent.test.jsx b/src/course-unit/add-component/AddComponent.test.jsx index 44befb33de..1bbf75fcd9 100644 --- a/src/course-unit/add-component/AddComponent.test.jsx +++ b/src/course-unit/add-component/AddComponent.test.jsx @@ -1,5 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; -import { render } from '@testing-library/react'; +import { + render, waitFor, within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { initializeMockApp } from '@edx/frontend-platform'; @@ -12,20 +14,21 @@ import { executeThunk } from '../../utils'; import { fetchCourseSectionVerticalData } from '../data/thunk'; import { getCourseSectionVerticalApiUrl } from '../data/api'; import { courseSectionVerticalMock } from '../__mocks__'; +import { COMPONENT_ICON_TYPES } from '../constants'; import AddComponent from './AddComponent'; import messages from './messages'; let store; let axiosMock; const blockId = '123'; -const handleCreateNewCourseXblockMock = jest.fn(); +const handleCreateNewCourseXBlockMock = jest.fn(); const renderComponent = (props) => render( @@ -100,7 +103,7 @@ describe('', () => { }); userEvent.click(customComponentButton); - expect(handleCreateNewCourseXblockMock).not.toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).not.toHaveBeenCalled(); }); it('calls handleCreateNewCourseXblock with correct parameters when Discussion xblock create button is clicked', () => { @@ -111,8 +114,8 @@ describe('', () => { }); userEvent.click(discussionButton); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ parentLocator: '123', type: 'discussion', }); @@ -126,14 +129,14 @@ describe('', () => { }); userEvent.click(discussionButton); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ parentLocator: '123', type: 'drag-and-drop-v2', }); }); - it('calls handleCreateNewCourseXblock with correct parameters when Problem xblock create button is clicked', () => { + it('calls handleCreateNewCourseXBlock with correct parameters when Problem xblock create button is clicked', () => { const { getByRole } = renderComponent(); const discussionButton = getByRole('button', { @@ -141,14 +144,14 @@ describe('', () => { }); userEvent.click(discussionButton); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ parentLocator: '123', type: 'problem', }, expect.any(Function)); }); - it('calls handleCreateNewCourseXblock with correct parameters when Video xblock create button is clicked', () => { + it('calls handleCreateNewCourseXBlock with correct parameters when Video xblock create button is clicked', () => { const { getByRole } = renderComponent(); const discussionButton = getByRole('button', { @@ -156,10 +159,187 @@ describe('', () => { }); userEvent.click(discussionButton); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); - expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ parentLocator: '123', type: 'video', }, expect.any(Function)); }); + + it('creates new "Library" xblock on click', () => { + const { getByRole } = renderComponent(); + + const discussionButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Library Content`, 'i'), + }); + + userEvent.click(discussionButton); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ + parentLocator: '123', + category: 'library_content', + type: 'library', + }); + }); + + it('verifies modal behavior on button click', async () => { + const { getByRole, queryByRole } = renderComponent(); + const advancedBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'), + }); + + userEvent.click(advancedBtn); + const modalContainer = getByRole('dialog'); + + expect(within(modalContainer).getByRole('button', { name: messages.modalContainerCancelBtnText.defaultMessage })).toBeInTheDocument(); + expect(within(modalContainer).getByRole('button', { name: messages.modalBtnText.defaultMessage })).toBeInTheDocument(); + + userEvent.click(within(modalContainer).getByRole('button', { name: messages.modalContainerCancelBtnText.defaultMessage })); + + expect(queryByRole('button', { name: messages.modalContainerCancelBtnText.defaultMessage })).toBeNull(); + expect(queryByRole('button', { name: messages.modalBtnText.defaultMessage })).toBeNull(); + }); + + it('verifies "Advanced" component selection in modal', async () => { + const { getByRole, getByText } = renderComponent(); + const advancedBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'), + }); + const componentTemplates = courseSectionVerticalMock.component_templates; + + userEvent.click(advancedBtn); + const modalContainer = getByRole('dialog'); + + await waitFor(() => { + expect(getByText(/Add advanced component/i)).toBeInTheDocument(); + componentTemplates.forEach((componentTemplate) => { + if (componentTemplate.type === COMPONENT_ICON_TYPES.advanced) { + componentTemplate.templates.forEach((template) => { + expect(within(modalContainer).getByRole('radio', { name: template.display_name })).toBeInTheDocument(); + }); + } + }); + }); + }); + + it('verifies "Text" component selection in modal', async () => { + const { getByRole, getByText } = renderComponent(); + const textBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Text`, 'i'), + }); + const componentTemplates = courseSectionVerticalMock.component_templates; + userEvent.click(textBtn); + const modalContainer = getByRole('dialog'); + + await waitFor(() => { + expect(getByText(/Add text component/i)).toBeInTheDocument(); + componentTemplates.forEach((componentTemplate) => { + if (componentTemplate.type === COMPONENT_ICON_TYPES.html) { + componentTemplate.templates.forEach((template) => { + expect(within(modalContainer).getByRole('radio', { name: template.display_name })).toBeInTheDocument(); + }); + } + }); + }); + }); + + it('verifies "Open Response" component selection in modal', async () => { + const { getByRole, getByText } = renderComponent(); + const openResponseBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Open Response`, 'i'), + }); + const componentTemplates = courseSectionVerticalMock.component_templates; + + userEvent.click(openResponseBtn); + const modalContainer = getByRole('dialog'); + + await waitFor(() => { + expect(getByText(/Add open response component/i)).toBeInTheDocument(); + componentTemplates.forEach((componentTemplate) => { + if (componentTemplate.type === COMPONENT_ICON_TYPES.openassessment) { + componentTemplate.templates.forEach((template) => { + expect(within(modalContainer).getByRole('radio', { name: template.display_name })).toBeInTheDocument(); + }); + } + }); + }); + }); + + it('verifies "Advanced" component creation and submission in modal', () => { + const { getByRole } = renderComponent(); + const advancedButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'), + }); + + userEvent.click(advancedButton); + const modalContainer = getByRole('dialog'); + + const radioInput = within(modalContainer).getByRole('radio', { name: 'Annotation' }); + const sendBtn = within(modalContainer).getByRole('button', { name: messages.modalBtnText.defaultMessage }); + + expect(sendBtn).toBeDisabled(); + userEvent.click(radioInput); + expect(sendBtn).not.toBeDisabled(); + + userEvent.click(sendBtn); + + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ + parentLocator: '123', + type: 'annotatable', + category: 'annotatable', + }); + }); + + it('verifies "Text" component creation and submission in modal', () => { + const { getByRole } = renderComponent(); + const advancedButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Text`, 'i'), + }); + + userEvent.click(advancedButton); + const modalContainer = getByRole('dialog'); + + const radioInput = within(modalContainer).getByRole('radio', { name: 'Text' }); + const sendBtn = within(modalContainer).getByRole('button', { name: messages.modalBtnText.defaultMessage }); + + expect(sendBtn).toBeDisabled(); + userEvent.click(radioInput); + expect(sendBtn).not.toBeDisabled(); + + userEvent.click(sendBtn); + + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ + parentLocator: '123', + type: 'html', + boilerplate: 'html', + }, expect.any(Function)); + }); + + it('verifies "Open Response" component creation and submission in modal', () => { + const { getByRole } = renderComponent(); + const advancedButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Open Response`, 'i'), + }); + + userEvent.click(advancedButton); + const modalContainer = getByRole('dialog'); + + const radioInput = within(modalContainer).getByRole('radio', { name: 'Peer Assessment Only' }); + const sendBtn = within(modalContainer).getByRole('button', { name: messages.modalBtnText.defaultMessage }); + + expect(sendBtn).toBeDisabled(); + userEvent.click(radioInput); + expect(sendBtn).not.toBeDisabled(); + + userEvent.click(sendBtn); + + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ + parentLocator: '123', + category: 'openassessment', + boilerplate: 'peer-assessment', + }); + }); }); diff --git a/src/course-unit/add-component/ComponentIcon.jsx b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx similarity index 64% rename from src/course-unit/add-component/ComponentIcon.jsx rename to src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx index b064d6f2d5..6d0f9c162f 100644 --- a/src/course-unit/add-component/ComponentIcon.jsx +++ b/src/course-unit/add-component/add-component-btn/AddComponentIcon.jsx @@ -2,16 +2,16 @@ import PropTypes from 'prop-types'; import { Icon } from '@openedx/paragon'; import { EditNote as EditNoteIcon } from '@openedx/paragon/icons'; -import { COMPONENT_TYPE_ICON_MAP, COMPONENT_ICON_TYPES } from '../constants'; +import { COMPONENT_ICON_TYPES, COMPONENT_TYPE_ICON_MAP } from '../../constants'; -const ComponentIcon = ({ type }) => { +const AddComponentIcon = ({ type }) => { const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon; return ; }; -ComponentIcon.propTypes = { +AddComponentIcon.propTypes = { type: PropTypes.oneOf(Object.values(COMPONENT_ICON_TYPES)).isRequired, }; -export default ComponentIcon; +export default AddComponentIcon; diff --git a/src/course-unit/add-component/add-component-btn/index.jsx b/src/course-unit/add-component/add-component-btn/index.jsx new file mode 100644 index 0000000000..a232c36191 --- /dev/null +++ b/src/course-unit/add-component/add-component-btn/index.jsx @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import { Button } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import AddComponentIcon from './AddComponentIcon'; + +const AddComponentButton = ({ type, displayName, onClick }) => { + const intl = useIntl(); + + return ( + + ); +}; + +AddComponentButton.propTypes = { + type: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default AddComponentButton; diff --git a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx new file mode 100644 index 0000000000..cba0ad0bdb --- /dev/null +++ b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Form } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { updateQueryPendingStatus } from '../../data/slice'; +import AddComponentButton from '../add-component-btn'; +import messages from '../messages'; +import ModalContainer from './ModalContainer'; + +const ComponentModalView = ({ + component, + modalParams, + handleCreateNewXBlock, +}) => { + const intl = useIntl(); + const dispatch = useDispatch(); + const [moduleTitle, setModuleTitle] = useState(''); + const { open, close, isOpen } = modalParams; + const { type, displayName, templates } = component; + + const handleSubmit = () => { + handleCreateNewXBlock(type, moduleTitle); + dispatch(updateQueryPendingStatus(true)); + setModuleTitle(''); + }; + + return ( + <> +
  • + +
  • + setModuleTitle('')} + hasValue={!moduleTitle.length} + > + + setModuleTitle(e.target.value)} + > + {templates.map((componentTemplate) => { + const value = componentTemplate.boilerplateName || componentTemplate.category; + + return ( + + {componentTemplate.displayName} + + ); + })} + + + + + ); +}; + +ComponentModalView.propTypes = { + modalParams: PropTypes.shape({ + open: PropTypes.func, + close: PropTypes.func, + isOpen: PropTypes.bool, + }).isRequired, + handleCreateNewXBlock: PropTypes.func.isRequired, + component: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + category: PropTypes.string, + type: PropTypes.string.isRequired, + templates: PropTypes.arrayOf( + PropTypes.shape({ + boilerplateName: PropTypes.string, + category: PropTypes.string, + displayName: PropTypes.string.isRequired, + }), + ), + }).isRequired, +}; + +export default ComponentModalView; diff --git a/src/course-unit/add-component/add-component-modals/ModalContainer.jsx b/src/course-unit/add-component/add-component-modals/ModalContainer.jsx new file mode 100644 index 0000000000..047e26d34f --- /dev/null +++ b/src/course-unit/add-component/add-component-modals/ModalContainer.jsx @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import { ActionRow, Button, StandardModal } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; + +const ModalContainer = ({ + title, isOpen, close, children, btnText, size, onSubmit, hasValue, resetDisabled, +}) => { + const intl = useIntl(); + + const handleSubmit = () => { + onSubmit(); + close(); + }; + + const handleClose = () => { + resetDisabled(); + close(); + }; + + return ( + + + + + + )} + > + {children} + + ); +}; + +ModalContainer.propTypes = { + title: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, + btnText: PropTypes.string.isRequired, + size: PropTypes.string, + onSubmit: PropTypes.func.isRequired, + hasValue: PropTypes.bool.isRequired, + resetDisabled: PropTypes.func.isRequired, +}; + +ModalContainer.defaultProps = { + size: 'md', +}; + +export default ModalContainer; diff --git a/src/course-unit/add-component/messages.js b/src/course-unit/add-component/messages.js index 94e6bf4833..959b702920 100644 --- a/src/course-unit/add-component/messages.js +++ b/src/course-unit/add-component/messages.js @@ -9,6 +9,18 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.add.component.button.text', defaultMessage: 'Add Component:', }, + modalBtnText: { + id: 'course-authoring.course-unit.modal.button.text', + defaultMessage: 'Select', + }, + modalContainerTitle: { + id: 'course-authoring.course-unit.modal.container.title', + defaultMessage: 'Add {componentTitle} component', + }, + modalContainerCancelBtnText: { + id: 'course-authoring.course-unit.modal.container.cancel.button.text', + defaultMessage: 'Cancel', + }, }); export default messages; diff --git a/src/course-unit/course-sequence/Sequence.jsx b/src/course-unit/course-sequence/Sequence.jsx index 09477dbc62..7729bba5a6 100644 --- a/src/course-unit/course-sequence/Sequence.jsx +++ b/src/course-unit/course-sequence/Sequence.jsx @@ -13,6 +13,7 @@ const Sequence = ({ courseId, sequenceId, unitId, + handleCreateNewCourseXBlock, }) => { const intl = useIntl(); const { IN_PROGRESS, FAILED, SUCCESSFUL } = RequestStatus; @@ -26,6 +27,7 @@ const Sequence = ({ sequenceId={sequenceId} unitId={unitId} courseId={courseId} + handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} /> @@ -58,6 +60,7 @@ Sequence.propTypes = { unitId: PropTypes.string, courseId: PropTypes.string.isRequired, sequenceId: PropTypes.string, + handleCreateNewCourseXBlock: PropTypes.func.isRequired, }; Sequence.defaultProps = { diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js index a4ff2f9a98..043a693fff 100644 --- a/src/course-unit/course-sequence/hooks.js +++ b/src/course-unit/course-sequence/hooks.js @@ -3,36 +3,23 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { useWindowSize } from '@openedx/paragon'; import { useModel } from '../../generic/model-store'; -import { RequestStatus } from '../../data/constants'; -import { getCourseSectionVertical, getSequenceStatus, sequenceIdsSelector } from '../data/selectors'; +import { + getCourseSectionVertical, + getCourseUnit, + sequenceIdsSelector, +} from '../data/selectors'; export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) { - const { SUCCESSFUL } = RequestStatus; const sequenceIds = useSelector(sequenceIdsSelector); - const sequenceStatus = useSelector(getSequenceStatus); const { nextUrl, prevUrl } = useSelector(getCourseSectionVertical); const sequence = useModel('sequences', currentSequenceId); - const { courseId, status } = useSelector(state => state.courseDetail); - - const isCourseOrSequenceNotSuccessful = status !== SUCCESSFUL || sequenceStatus !== SUCCESSFUL; - const areIdsNotValid = !currentSequenceId || !currentUnitId || !sequence.unitIds; - const isNotSuccessfulCompletion = isCourseOrSequenceNotSuccessful || areIdsNotValid; - - // If we don't know the sequence and unit yet, then assume no. - if (isNotSuccessfulCompletion) { - return { isFirstUnit: false, isLastUnit: false }; - } + const { courseId } = useSelector(getCourseUnit); + const isFirstUnit = !prevUrl; + const isLastUnit = !nextUrl; const sequenceIndex = sequenceIds.indexOf(currentSequenceId); const unitIndex = sequence.unitIds.indexOf(currentUnitId); - const isFirstSequence = sequenceIndex === 0; - const isFirstUnitInSequence = unitIndex === 0; - const isFirstUnit = isFirstSequence && isFirstUnitInSequence; - const isLastSequence = sequenceIndex === sequenceIds.length - 1; - const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1; - const isLastUnit = isLastSequence && isLastUnitInSequence; - const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null; const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx index 79d7d3d2c9..e7a47ec748 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -23,6 +23,7 @@ const SequenceNavigation = ({ unitId, sequenceId, className, + handleCreateNewCourseXBlock, }) => { const sequenceStatus = useSelector(getSequenceStatus); const { @@ -42,6 +43,7 @@ const SequenceNavigation = ({ ); }; @@ -105,6 +107,7 @@ SequenceNavigation.propTypes = { unitId: PropTypes.string, className: PropTypes.string, sequenceId: PropTypes.string, + handleCreateNewCourseXBlock: PropTypes.func.isRequired, }; SequenceNavigation.defaultProps = { diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx index e0b9778691..e601ce2f3b 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx @@ -1,16 +1,17 @@ import PropTypes from 'prop-types'; -import { Dropdown } from '@openedx/paragon'; +import { Button, Dropdown } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { Plus as PlusIcon } from '@openedx/paragon/icons/'; import messages from '../messages'; import UnitButton from './UnitButton'; -const SequenceNavigationDropdown = ({ unitId, unitIds }) => { +const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => { const intl = useIntl(); return ( - + {intl.formatMessage(messages.sequenceDropdownTitle, { current: unitIds.indexOf(unitId) + 1, total: unitIds.length, @@ -27,6 +28,14 @@ const SequenceNavigationDropdown = ({ unitId, unitIds }) => { unitId={buttonUnitId} /> ))} + ); @@ -35,6 +44,7 @@ const SequenceNavigationDropdown = ({ unitId, unitIds }) => { SequenceNavigationDropdown.propTypes = { unitId: PropTypes.string.isRequired, unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, + handleClick: PropTypes.func.isRequired, }; export default SequenceNavigationDropdown; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index d287305d5e..370488ce06 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -1,16 +1,24 @@ +import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; -import { Link } from 'react-router-dom'; import { Button } from '@openedx/paragon'; import { Plus as PlusIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { useNavigate } from 'react-router-dom'; -import { useIndexOfLastVisibleChild } from '../hooks'; +import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice'; +import { getCourseId, getSequenceId } from '../../data/selectors'; import messages from '../messages'; +import { useIndexOfLastVisibleChild } from '../hooks'; import SequenceNavigationDropdown from './SequenceNavigationDropdown'; import UnitButton from './UnitButton'; -const SequenceNavigationTabs = ({ unitIds, unitId }) => { +const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock }) => { const intl = useIntl(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const sequenceId = useSelector(getSequenceId); + const courseId = useSelector(getCourseId); + const [ indexOfLastVisibleChild, containerRef, @@ -18,6 +26,14 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => { ] = useIndexOfLastVisibleChild(); const shouldDisplayDropdown = indexOfLastVisibleChild === -1; + const handleAddNewSequenceUnit = () => { + dispatch(updateQueryPendingStatus(true)); + handleCreateNewCourseXBlock({ parentLocator: sequenceId, category: 'vertical', displayName: 'Unit' }, ({ courseKey, locator }) => { + navigate(`/course/${courseKey}/container/${locator}/${sequenceId}`, courseId); + dispatch(changeEditTitleFormOpen(true)); + }); + }; + return (
    @@ -32,13 +48,11 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => { isActive={unitId === buttonUnitId} /> ))} - {/* TODO: The functionality of the New unit button will be implemented in https://youtrack.raccoongang.com/issue/AXIMST-14 */} @@ -48,6 +62,7 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => { )}
    @@ -57,6 +72,7 @@ const SequenceNavigationTabs = ({ unitIds, unitId }) => { SequenceNavigationTabs.propTypes = { unitId: PropTypes.string.isRequired, unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, + handleCreateNewCourseXBlock: PropTypes.func.isRequired, }; export default SequenceNavigationTabs; diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx index 71918bc152..bb9801077e 100644 --- a/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx @@ -4,12 +4,13 @@ import { Button } from '@openedx/paragon'; import { Link } from 'react-router-dom'; import UnitIcon from './UnitIcon'; +import { getCourseId, getSequenceId } from '../../data/selectors'; const UnitButton = ({ title, contentType, isActive, unitId, className, showTitle, }) => { - const courseId = useSelector(state => state.courseUnit.courseId); - const sequenceId = useSelector(state => state.courseUnit.sequenceId); + const courseId = useSelector(getCourseId); + const sequenceId = useSelector(getSequenceId); return (