diff --git a/package-lock.json b/package-lock.json index 1909c12ef..ac5517e3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { + "@dnd-kit/sortable": "^8.0.0", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-component-ai-translations-edx": "^1.4.2", "@edx/frontend-component-footer": "^12.3.0", @@ -2337,9 +2338,9 @@ } }, "node_modules/@dnd-kit/accessibility": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", - "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", "dependencies": { "tslib": "^2.0.0" }, @@ -2348,12 +2349,12 @@ } }, "node_modules/@dnd-kit/core": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", - "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", "dependencies": { - "@dnd-kit/accessibility": "^3.0.0", - "@dnd-kit/utilities": "^3.2.1", + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { @@ -2362,22 +2363,22 @@ } }, "node_modules/@dnd-kit/sortable": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", - "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", "dependencies": { - "@dnd-kit/utilities": "^3.2.0", + "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.0.7", + "@dnd-kit/core": "^6.1.0", "react": ">=16.8.0" } }, "node_modules/@dnd-kit/utilities": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", - "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", "dependencies": { "tslib": "^2.0.0" }, @@ -2811,6 +2812,19 @@ "react-dom": "^16.14.0 || ^17.0.0" } }, + "node_modules/@edx/frontend-lib-content-components/node_modules/@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.7", + "react": ">=16.8.0" + } + }, "node_modules/@edx/frontend-lib-content-components/node_modules/react-responsive": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", diff --git a/package.json b/package.json index 39f73abff..e0b2b0c1b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "url": "https://github.com/openedx/frontend-app-course-authoring/issues" }, "dependencies": { + "@dnd-kit/sortable": "^8.0.0", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-component-ai-translations-edx": "^1.4.2", "@edx/frontend-component-footer": "^12.3.0", diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 25cf0a387..b593d29b9 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -21,6 +21,7 @@ import { DraggableList, ErrorAlert, } from '@edx/frontend-lib-content-components'; +import { arrayMove } from '@dnd-kit/sortable'; import { LoadingSpinner } from '../generic/Loading'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; @@ -148,6 +149,86 @@ const CourseOutline = ({ courseId }) => { }); }; + /** + * Check if item can be moved by given step. + * Inner function returns false if the new index after moving by given step + * is out of bounds of item length. + * If it is within bounds, returns draggable flag of the item in the new index. + * This helps us avoid moving the item to a position of unmovable item. + * @param {Array} items + * @returns {(id, step) => bool} + */ + const canMoveItem = (items) => (id, step) => { + const newId = id + step; + const indexCheck = newId >= 0 && newId < items.length; + if (!indexCheck) { + return false; + } + const newItem = items[newId]; + return newItem.actions.draggable; + }; + + /** + * Move section to new index + * @param {any} currentIndex + * @param {any} newIndex + */ + const updateSectionOrderByIndex = (currentIndex, newIndex) => { + if (currentIndex === newIndex) { + return; + } + setSections((prevSections) => { + const newSections = arrayMove(prevSections, currentIndex, newIndex); + finalizeSectionOrder()(newSections); + return newSections; + }); + }; + + /** + * Returns a function for given section which can move a subsection inside it + * to a new position + * @param {any} sectionIndex + * @param {any} section + * @param {any} subsections + * @returns {(currentIndex, newIndex) => void} + */ + const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => { + if (currentIndex === newIndex) { + return; + } + setSubsection(sectionIndex)(() => { + const newSubsections = arrayMove(subsections, currentIndex, newIndex); + finalizeSubsectionOrder(section)()(newSubsections); + return newSubsections; + }); + }; + + /** + * Returns a function for given section & subsection which can move a unit + * inside it to a new position + * @param {any} sectionIndex + * @param {any} section + * @param {any} subsection + * @param {any} units + * @returns {(currentIndex, newIndex) => void} + */ + const updateUnitOrderByIndex = ( + sectionIndex, + subsectionIndex, + section, + subsection, + units, + ) => (currentIndex, newIndex) => { + if (currentIndex === newIndex) { + return; + } + setUnit(sectionIndex, subsectionIndex)(() => { + const newUnits = arrayMove(units, currentIndex, newIndex); + finalizeUnitOrder(section, subsection)()(newUnits); + return newUnits; + }); + }; + useEffect(() => { setSections(sectionsList); }, [sectionsList]); @@ -228,6 +309,8 @@ const CourseOutline = ({ courseId }) => { id={section.id} key={section.id} section={section} + index={sectionIndex} + canMoveItem={canMoveItem(sections)} savingStatus={savingStatus} onOpenHighlightsModal={handleOpenHighlightsModal} onOpenPublishModal={openPublishModal} @@ -237,6 +320,7 @@ const CourseOutline = ({ courseId }) => { onDuplicateSubmit={handleDuplicateSectionSubmit} isSectionsExpanded={isSectionsExpanded} onNewSubsectionSubmit={handleNewSubsectionSubmit} + onOrderChange={updateSectionOrderByIndex} > { key={subsection.id} section={section} subsection={subsection} + index={subsectionIndex} + canMoveItem={canMoveItem(section.childInfo.children)} savingStatus={savingStatus} onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSubsectionSubmit} onNewUnitSubmit={handleNewUnitSubmit} + onOrderChange={updateSubsectionOrderByIndex( + sectionIndex, + section, + section.childInfo.children, + )} > - {subsection.childInfo.children.map((unit) => ( + {subsection.childInfo.children.map((unit, unitIndex) => ( ))} diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index e5e1045e9..1fe8f9893 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -686,6 +686,241 @@ describe('', () => { }); }); + it('check whether section move up and down options work correctly', async () => { + const { findAllByTestId } = render(); + // get second section element + const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [, sectionElement] = await findAllByTestId('section-card'); + + // mock api call + axiosMock + .onPut(getCourseBlockApiUrl(courseBlockId)) + .reply(200, { dummy: 'value' }); + + // find menu button and click on it to open menu + const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); + fireEvent.click(menu); + + // move second section to first position to test move up option + const moveUpButton = await within(sectionElement).findByTestId('section-card-header__menu-move-up-button'); + await act(async () => fireEvent.click(moveUpButton)); + const firstSectionId = store.getState().courseOutline.sectionsList[0].id; + expect(secondSection.id).toBe(firstSectionId); + + // move first section back to second position to test move down option + const moveDownButton = await within(sectionElement).findByTestId('section-card-header__menu-move-down-button'); + await act(async () => fireEvent.click(moveDownButton)); + const newSecondSectionId = store.getState().courseOutline.sectionsList[1].id; + expect(secondSection.id).toBe(newSecondSectionId); + }); + + it('check whether section move up & down option is rendered correctly based on index', async () => { + const { findAllByTestId } = render(); + // get first, second and last section element + const { + 0: firstSection, 1: secondSection, length, [length - 1]: lastSection, + } = await findAllByTestId('section-card'); + + // find menu button and click on it to open menu in first section + const firstMenu = await within(firstSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(firstMenu)); + // move down option should be enabled in first element + expect( + await within(firstSection).findByTestId('section-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + // move up option should not be enabled in first element + expect( + await within(firstSection).findByTestId('section-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + + // find menu button and click on it to open menu in second section + const secondMenu = await within(secondSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(secondMenu)); + // both move down & up option should be enabled in second element + expect( + await within(secondSection).findByTestId('section-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + expect( + await within(secondSection).findByTestId('section-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + + // find menu button and click on it to open menu in last section + const lastMenu = await within(lastSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(lastMenu)); + // move down option should not be enabled in last element + expect( + await within(lastSection).findByTestId('section-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + // move up option should be enabled in last element + expect( + await within(lastSection).findByTestId('section-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + }); + + it('check whether subsection move up and down options work correctly', async () => { + const { findAllByTestId } = render(); + // get second section element + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [sectionElement] = await findAllByTestId('section-card'); + const [, secondSubsection] = section.childInfo.children; + const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + + // mock api call + axiosMock + .onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[0].id)) + .reply(200, { dummy: 'value' }); + + // find menu button and click on it to open menu + const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + + // move second subsection to first position to test move up option + const moveUpButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-up-button'); + await act(async () => fireEvent.click(moveUpButton)); + const firstSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id; + expect(secondSubsection.id).toBe(firstSubsectionId); + + // move first section back to second position to test move down option + const moveDownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-down-button'); + await act(async () => fireEvent.click(moveDownButton)); + const secondSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id; + expect(secondSubsection.id).toBe(secondSubsectionId); + }); + + it('check whether subsection move up & down option is rendered correctly based on index', async () => { + const { findAllByTestId } = render(); + // using second section as second section in mock has 3 subsections + const [, sectionElement] = await findAllByTestId('section-card'); + // get first, second and last subsection element + const { + 0: firstSubsection, + 1: secondSubsection, + length, + [length - 1]: lastSubsection, + } = await within(sectionElement).findAllByTestId('subsection-card'); + + // find menu button and click on it to open menu in first section + const firstMenu = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(firstMenu)); + // move down option should be enabled in first element + expect( + await within(firstSubsection).findByTestId('subsection-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + // move up option should not be enabled in first element + expect( + await within(firstSubsection).findByTestId('subsection-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + + // find menu button and click on it to open menu in second section + const secondMenu = await within(secondSubsection).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(secondMenu)); + // both move down & up option should be enabled in second element + expect( + await within(secondSubsection).findByTestId('subsection-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + expect( + await within(secondSubsection).findByTestId('subsection-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + + // find menu button and click on it to open menu in last section + const lastMenu = await within(lastSubsection).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(lastMenu)); + // move down option should not be enabled in last element + expect( + await within(lastSubsection).findByTestId('subsection-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + // move up option should be enabled in last element + expect( + await within(lastSubsection).findByTestId('subsection-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + }); + + it('check whether unit move up and down options work correctly', async () => { + const { findAllByTestId } = render(); + // get second section -> second subsection -> second unit element + const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [, sectionElement] = await findAllByTestId('section-card'); + const [, subsection] = section.childInfo.children; + const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + await act(async () => fireEvent.click(expandBtn)); + const [, secondUnit] = subsection.childInfo.children; + const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + // mock api call + axiosMock + .onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id)) + .reply(200, { dummy: 'value' }); + + // find menu button and click on it to open menu + const menu = await within(unitElement).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + + // move second unit to first position to test move up option + const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button'); + await act(async () => fireEvent.click(moveUpButton)); + const firstUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[0].id; + expect(secondUnit.id).toBe(firstUnitId); + + // move first unit back to second position to test move down option + const moveDownButton = await within(subsectionElement).findByTestId('unit-card-header__menu-move-down-button'); + await act(async () => fireEvent.click(moveDownButton)); + const secondUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[1].id; + expect(secondUnit.id).toBe(secondUnitId); + }); + + it('check whether unit move up & down option is rendered correctly based on index', async () => { + const { findAllByTestId } = render(); + // using second section -> second subsection as it has 5 units in mock. + const [, sectionElement] = await findAllByTestId('section-card'); + const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + await act(async () => fireEvent.click(expandBtn)); + // get first, second and last unit element + const { + 0: firstUnit, + 1: secondUnit, + length, + [length - 1]: lastUnit, + } = await within(subsectionElement).findAllByTestId('unit-card'); + + // find menu button and click on it to open menu in first section + const firstMenu = await within(firstUnit).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(firstMenu)); + // move down option should be enabled in first element + expect( + await within(firstUnit).findByTestId('unit-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + // move up option should not be enabled in first element + expect( + await within(firstUnit).findByTestId('unit-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + + // find menu button and click on it to open menu in second section + const secondMenu = await within(secondUnit).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(secondMenu)); + // both move down & up option should be enabled in second element + expect( + await within(secondUnit).findByTestId('unit-card-header__menu-move-down-button'), + ).not.toHaveAttribute('aria-disabled'); + expect( + await within(secondUnit).findByTestId('unit-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + + // find menu button and click on it to open menu in last section + const lastMenu = await within(lastUnit).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(lastMenu)); + // move down option should not be enabled in last element + expect( + await within(lastUnit).findByTestId('unit-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + // move up option should be enabled in last element + expect( + await within(lastUnit).findByTestId('unit-card-header__menu-move-up-button'), + ).not.toHaveAttribute('aria-disabled'); + }); + it('check that new section list is saved when dragged', async () => { const { findAllByRole } = render(); const courseBlockId = courseOutlineIndexMock.courseStructure.id; @@ -865,10 +1100,12 @@ describe('', () => { }, }, }); - const { queryByTestId } = render(); + const { findAllByTestId } = render(); + const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; + const [sectionElement] = await findAllByTestId('conditional-sortable-element--no-drag-handle') await waitFor(() => { - expect(queryByTestId('conditional-sortable-element--no-drag-handle')).toBeInTheDocument(); + expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument(); }); }); }); diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index ee8099efd..6adb6f849 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -30,6 +30,8 @@ const CardHeader = ({ isDisabledEditField, onClickDelete, onClickDuplicate, + onClickMoveUp, + onClickMoveDown, titleComponent, namePrefix, actions, @@ -112,8 +114,27 @@ const CardHeader = ({ {intl.formatMessage(messages.menuDuplicate)} )} + {actions.draggable && ( + <> + + {intl.formatMessage(messages.menuMoveUp)} + + + {intl.formatMessage(messages.menuMoveDown)} + + + )} {actions.deletable && ( @@ -141,6 +162,8 @@ CardHeader.propTypes = { isDisabledEditField: PropTypes.bool.isRequired, onClickDelete: PropTypes.func.isRequired, onClickDuplicate: PropTypes.func.isRequired, + onClickMoveUp: PropTypes.func.isRequired, + onClickMoveDown: PropTypes.func.isRequired, titleComponent: PropTypes.node.isRequired, namePrefix: PropTypes.string.isRequired, actions: PropTypes.shape({ @@ -148,6 +171,8 @@ CardHeader.propTypes = { draggable: PropTypes.bool.isRequired, childAddable: PropTypes.bool.isRequired, duplicable: PropTypes.bool.isRequired, + allowMoveUp: PropTypes.bool, + allowMoveDown: PropTypes.bool, }).isRequired, }; diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js index 751ba4815..b268c430b 100644 --- a/src/course-outline/card-header/messages.js +++ b/src/course-outline/card-header/messages.js @@ -41,6 +41,14 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.menu.duplicate', defaultMessage: 'Duplicate', }, + menuMoveUp: { + id: 'course-authoring.course-outline.card.menu.moveup', + defaultMessage: 'Move up', + }, + menuMoveDown: { + id: 'course-authoring.course-outline.card.menu.movedown', + defaultMessage: 'Move down', + }, menuDelete: { id: 'course-authoring.course-outline.card.menu.delete', defaultMessage: 'Delete', diff --git a/src/course-outline/publish-modal/PublishModal.jsx b/src/course-outline/publish-modal/PublishModal.jsx index d14057370..e34fade86 100644 --- a/src/course-outline/publish-modal/PublishModal.jsx +++ b/src/course-outline/publish-modal/PublishModal.jsx @@ -10,6 +10,7 @@ import { import { useSelector } from 'react-redux'; import { getCurrentItem } from '../data/selectors'; +import { COURSE_BLOCK_NAMES } from '../constants'; import messages from './messages'; const PublishModal = ({ @@ -19,6 +20,7 @@ const PublishModal = ({ }) => { const intl = useIntl(); const { displayName, childInfo, category } = useSelector(getCurrentItem); + const categoryName = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); const children = childInfo?.children || []; return ( @@ -35,7 +37,9 @@ const PublishModal = ({ -

{intl.formatMessage(messages.description, { category })}

+

+ {intl.formatMessage(messages.description, { category: categoryName })} +

{children.filter(child => child.hasChanges).map((child) => { let grandChildren = child.childInfo?.children || []; grandChildren = grandChildren.filter(grandChild => grandChild.hasChanges); diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 618131682..9787c0a82 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -6,6 +6,7 @@ import { useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Badge, Button, useToggle } from '@edx/paragon'; import { Add as IconAdd } from '@edx/paragon/icons'; +import classNames from 'classnames'; import { setCurrentItem, setCurrentSection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; @@ -19,6 +20,8 @@ import messages from './messages'; const SectionCard = ({ section, children, + index, + canMoveItem, onOpenHighlightsModal, onOpenPublishModal, onOpenConfigureModal, @@ -28,6 +31,7 @@ const SectionCard = ({ onDuplicateSubmit, isSectionsExpanded, onNewSubsectionSubmit, + onOrderChange, }) => { const currentRef = useRef(null); const intl = useIntl(); @@ -54,11 +58,17 @@ const SectionCard = ({ published, visibilityState, highlights, - actions, + actions: sectionActions, isHeaderVisible = true, explanatoryMessage = '', } = section; + // re-create actions object for customizations + const actions = { ...sectionActions }; + // add actions to control display of move up & down menu buton. + actions.allowMoveUp = canMoveItem(index, -1); + actions.allowMoveDown = canMoveItem(index, 1); + const sectionStatus = getItemStatus({ published, visibilityState, @@ -95,6 +105,14 @@ const SectionCard = ({ onNewSubsectionSubmit(id); }; + const handleSectionMoveUp = () => { + onOrderChange(index, index - 1); + }; + + const handleSectionMoveDown = () => { + onOrderChange(index, index + 1); + }; + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { closeForm(); @@ -115,10 +133,12 @@ const SectionCard = ({ ); + const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); + return ( {isExpanded && ( -
+
{children} {actions.childAddable && (