From da68fb8e9d47cc5f666ac3e7794a0ff190be98cd Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 19 Mar 2024 19:55:02 +0530 Subject: [PATCH] feat: allow dragging blocks across parents in outline (#859) --- .env | 1 + .env.development | 1 + .env.test | 1 + package-lock.json | 16 + package.json | 3 + src/course-outline/CourseOutline.jsx | 344 +++++----- src/course-outline/CourseOutline.scss | 2 +- src/course-outline/CourseOutline.test.jsx | 587 +++++++++++++----- .../__mocks__/courseOutlineIndex.js | 6 +- src/course-outline/card-header/CardHeader.jsx | 3 +- .../card-header/StatusBadge.jsx | 2 +- src/course-outline/constants.js | 2 + src/course-outline/data/api.js | 2 +- src/course-outline/data/selectors.js | 1 + src/course-outline/data/slice.js | 29 +- src/course-outline/data/thunk.js | 123 ++-- .../ConditionalSortableElement.jsx | 57 -- .../drag-helper/DragContextProvider.jsx | 31 + .../drag-helper/DraggableList.jsx | 362 +++++++++++ .../drag-helper/SortableItem.jsx | 101 +++ ...SortableElement.scss => SortableItem.scss} | 0 src/course-outline/drag-helper/messages.js | 11 + src/course-outline/drag-helper/utils.js | 331 ++++++++++ src/course-outline/hooks.jsx | 55 +- src/course-outline/page-alerts/PageAlerts.jsx | 109 ++++ .../page-alerts/PageAlerts.test.jsx | 37 ++ src/course-outline/page-alerts/messages.js | 49 ++ .../section-card/SectionCard.jsx | 38 +- .../section-card/SectionCard.test.jsx | 6 +- .../subsection-card/SubsectionCard.jsx | 41 +- .../subsection-card/SubsectionCard.test.jsx | 12 +- src/course-outline/unit-card/UnitCard.jsx | 27 +- .../unit-card/UnitCard.test.jsx | 20 +- src/course-outline/utils.jsx | 16 +- .../xblock-status/GradingPolicyAlert.jsx | 3 +- .../xblock-status/ReleaseStatus.jsx | 6 +- .../xblock-status/StatusMessages.jsx | 7 +- .../xblock-status/XBlockStatus.jsx | 12 +- src/data/constants.js | 2 + src/generic/DraftIcon.jsx | 18 + src/index.jsx | 1 + 41 files changed, 1948 insertions(+), 527 deletions(-) delete mode 100644 src/course-outline/drag-helper/ConditionalSortableElement.jsx create mode 100644 src/course-outline/drag-helper/DragContextProvider.jsx create mode 100644 src/course-outline/drag-helper/DraggableList.jsx create mode 100644 src/course-outline/drag-helper/SortableItem.jsx rename src/course-outline/drag-helper/{ConditionalSortableElement.scss => SortableItem.scss} (100%) create mode 100644 src/course-outline/drag-helper/messages.js create mode 100644 src/course-outline/drag-helper/utils.js create mode 100644 src/generic/DraftIcon.jsx diff --git a/.env b/.env index 84914b08f2..d5de96d5eb 100644 --- a/.env +++ b/.env @@ -32,6 +32,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_UNIT_PAGE=false +ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_TAGGING_TAXONOMY_PAGES=false BBB_LEARN_MORE_URL='' diff --git a/.env.development b/.env.development index ed64eb4c6c..2ea52ee9fa 100644 --- a/.env.development +++ b/.env.development @@ -34,6 +34,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_UNIT_PAGE=false +ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' diff --git a/.env.test b/.env.test index c7ebc14402..d92d12aef6 100644 --- a/.env.test +++ b/.env.test @@ -30,6 +30,7 @@ ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true ENABLE_UNIT_PAGE=true +ENABLE_ASSETS_PAGE=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' diff --git a/package-lock.json b/package-lock.json index bdcde3f1c0..85899f669d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,10 @@ "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-component-ai-translations": "^2.0.0", "@edx/frontend-component-footer": "^13.0.2", @@ -2353,6 +2356,19 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/sortable": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", diff --git a/package.json b/package.json index 819533d1d1..8b4080ece2 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,10 @@ "url": "https://github.com/openedx/frontend-app-course-authoring/issues" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-component-ai-translations": "^2.0.0", "@edx/frontend-component-footer": "^13.0.2", diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 07d11a9f08..82af8687cc 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -15,8 +15,11 @@ import { Warning as WarningIcon, } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; -import { DraggableList } from '@edx/frontend-lib-content-components'; -import { arrayMove } from '@dnd-kit/sortable'; +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { LoadingSpinner } from '../generic/Loading'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; @@ -41,6 +44,12 @@ import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import PublishModal from './publish-modal/PublishModal'; import ConfigureModal from './configure-modal/ConfigureModal'; import PageAlerts from './page-alerts/PageAlerts'; +import DraggableList from './drag-helper/DraggableList'; +import { + canMoveSection, + possibleUnitMoves, + possibleSubsectionMoves, +} from './drag-helper/utils'; import { useCourseOutline } from './hooks'; import messages from './messages'; import useUnitTagsCount from './data/apiHooks'; @@ -92,10 +101,7 @@ const CourseOutline = ({ courseId }) => { handleNewSubsectionSubmit, handleNewUnitSubmit, getUnitUrl, - handleSectionDragAndDrop, - handleSubsectionDragAndDrop, handleVideoSharingOptionChange, - handleUnitDragAndDrop, handleCopyToClipboardClick, handlePasteClipboardClick, notificationDismissUrl, @@ -107,11 +113,17 @@ const CourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, handleDismissNotification, advanceSettingsUrl, + prevContainerInfo, + handleSectionDragAndDrop, + handleSubsectionDragAndDrop, + handleUnitDragAndDrop, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); - let initialSections = [...sectionsList]; + const restoreSectionList = () => { + setSections(() => [...sectionsList]); + }; const { isShow: isShowProcessingNotification, @@ -121,48 +133,6 @@ const CourseOutline = ({ courseId }) => { const { category } = useSelector(getCurrentItem); const deleteCategory = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); - const finalizeSectionOrder = () => (newSections) => { - initialSections = [...sectionsList]; - handleSectionDragAndDrop(newSections.map(section => section.id), () => { - setSections(() => initialSections); - }); - }; - - const setSubsection = (index) => (updatedSubsection) => { - const section = { ...sections[index] }; - section.childInfo = { ...section.childInfo }; - section.childInfo.children = updatedSubsection(); - setSections([...sections.slice(0, index), section, ...sections.slice(index + 1)]); - }; - - const finalizeSubsectionOrder = (section) => () => (newSubsections) => { - initialSections = [...sectionsList]; - handleSubsectionDragAndDrop(section.id, newSubsections.map(subsection => subsection.id), () => { - setSections(() => initialSections); - }); - }; - - const setUnit = (sectionIndex, subsectionIndex) => (updatedUnits) => { - const section = { ...sections[sectionIndex] }; - section.childInfo = { ...section.childInfo }; - - const subsection = { ...section.childInfo.children[subsectionIndex] }; - subsection.childInfo = { ...subsection.childInfo }; - subsection.childInfo.children = updatedUnits(); - - const updatedSubsections = [...section.childInfo.children]; - updatedSubsections[subsectionIndex] = subsection; - section.childInfo.children = updatedSubsections; - setSections([...sections.slice(0, sectionIndex), section, ...sections.slice(sectionIndex + 1)]); - }; - - const finalizeUnitOrder = (section, subsection) => () => (newUnits) => { - initialSections = [...sectionsList]; - handleUnitDragAndDrop(section.id, subsection.id, newUnits.map(unit => unit.id), () => { - setSections(() => initialSections); - }); - }; - const unitsIdPattern = useMemo(() => { let pattern = ''; sections.forEach((section) => { @@ -184,25 +154,6 @@ const CourseOutline = ({ courseId }) => { isSuccess: isUnitsTagCountsLoaded, } = useUnitTagsCount(unitsIdPattern); - /** - * 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 @@ -214,54 +165,58 @@ const CourseOutline = ({ courseId }) => { } setSections((prevSections) => { const newSections = arrayMove(prevSections, currentIndex, newIndex); - finalizeSectionOrder()(newSections); + handleSectionDragAndDrop(newSections.map(section => section.id)); return newSections; }); }; /** - * Returns a function for given section which can move a subsection inside it - * to a new position - * @param {any} sectionIndex + * Uses details from move information and moves subsection * @param {any} section - * @param {any} subsections - * @returns {(currentIndex, newIndex) => void} + * @param {any} moveDetails + * @returns {void} */ - const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => { - if (currentIndex === newIndex) { + const updateSubsectionOrderByIndex = (section, moveDetails) => { + const { fn, args, sectionId } = moveDetails; + if (!args) { return; } - setSubsection(sectionIndex)(() => { - const newSubsections = arrayMove(subsections, currentIndex, newIndex); - finalizeSubsectionOrder(section)()(newSubsections); - return newSubsections; - }); + const [sectionsCopy, newSubsections] = fn(...args); + if (newSubsections && sectionId) { + setSections(sectionsCopy); + handleSubsectionDragAndDrop( + sectionId, + section.id, + newSubsections.map(subsection => subsection.id), + restoreSectionList, + ); + } }; /** - * Returns a function for given section & subsection which can move a unit - * inside it to a new position - * @param {any} sectionIndex + * Uses details from move information and moves unit * @param {any} section - * @param {any} subsection - * @param {any} units - * @returns {(currentIndex, newIndex) => void} + * @param {any} moveDetails + * @returns {void} */ - const updateUnitOrderByIndex = ( - sectionIndex, - subsectionIndex, - section, - subsection, - units, - ) => (currentIndex, newIndex) => { - if (currentIndex === newIndex) { + const updateUnitOrderByIndex = (section, moveDetails) => { + const { + fn, args, sectionId, subsectionId, + } = moveDetails; + if (!args) { return; } - setUnit(sectionIndex, subsectionIndex)(() => { - const newUnits = arrayMove(units, currentIndex, newIndex); - finalizeUnitOrder(section, subsection)()(newUnits); - return newUnits; - }); + const [sectionsCopy, newUnits] = fn(...args); + if (newUnits && sectionId && subsectionId) { + setSections(sectionsCopy); + handleUnitDragAndDrop( + sectionId, + section.id, + subsectionId, + newUnits.map(unit => unit.id), + restoreSectionList, + ); + } }; useEffect(() => { @@ -285,6 +240,7 @@ const CourseOutline = ({ courseId }) => {
{
{sections.length ? ( <> - - {sections.map((section, sectionIndex) => ( - - + + {sections.map((section, sectionIndex) => ( + - {section.childInfo.children.map((subsection, subsectionIndex) => ( - - + {section.childInfo.children.map((subsection, subsectionIndex) => ( + - {subsection.childInfo.children.map((unit, unitIndex) => ( - - ))} - - - ))} - - - ))} + + {subsection.childInfo.children.map((unit, unitIndex) => ( + + ))} + + + ))} + + + ))} + {courseActions.childAddable && ( + )} + + ); +}; + +SortableItem.defaultProps = { + componentStyle: null, + isDroppable: true, + isDraggable: true, +}; + +SortableItem.propTypes = { + id: PropTypes.string.isRequired, + category: PropTypes.string.isRequired, + isDroppable: PropTypes.bool, + isDraggable: PropTypes.bool, + children: PropTypes.node.isRequired, + componentStyle: PropTypes.shape({}), + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(SortableItem); diff --git a/src/course-outline/drag-helper/ConditionalSortableElement.scss b/src/course-outline/drag-helper/SortableItem.scss similarity index 100% rename from src/course-outline/drag-helper/ConditionalSortableElement.scss rename to src/course-outline/drag-helper/SortableItem.scss diff --git a/src/course-outline/drag-helper/messages.js b/src/course-outline/drag-helper/messages.js new file mode 100644 index 0000000000..3a7263280b --- /dev/null +++ b/src/course-outline/drag-helper/messages.js @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + tooltipContent: { + id: 'authoring.draggableList.tooltip.content', + defaultMessage: 'Drag to reorder', + description: 'Tooltip content for drag indicator icon', + }, +}); + +export default messages; diff --git a/src/course-outline/drag-helper/utils.js b/src/course-outline/drag-helper/utils.js new file mode 100644 index 0000000000..efe1d7d956 --- /dev/null +++ b/src/course-outline/drag-helper/utils.js @@ -0,0 +1,331 @@ +import { arrayMove } from '@dnd-kit/sortable'; + +export const dragHelpers = { + copyBlockChildren: (block) => { + // eslint-disable-next-line no-param-reassign + block.childInfo = { ...block.childInfo }; + // eslint-disable-next-line no-param-reassign + block.childInfo.children = [...block.childInfo.children]; + return block; + }, + setBlockChildren: (block, children) => { + // eslint-disable-next-line no-param-reassign + block.childInfo.children = children; + return block; + }, + setBlockChild: (block, child, id) => { + // eslint-disable-next-line no-param-reassign + block.childInfo.children[id] = child; + return block; + }, + insertChild: (block, child, index) => { + // eslint-disable-next-line no-param-reassign + block.childInfo.children = [ + ...block.childInfo.children.slice(0, index), + child, + ...block.childInfo.children.slice(index, block.childInfo.children.length), + ]; + return block; + }, + isBelowOverItem: (active, over) => over + && active.rect.current.translated + && active.rect.current.translated.top + > over.rect.top + over.rect.height, +}; + +export const moveSubsectionOver = ( + prevCopy, + activeSectionIdx, + activeSubsectionIdx, + overSectionIdx, + newIndex, +) => { + let activeSection = dragHelpers.copyBlockChildren({ ...prevCopy[activeSectionIdx] }); + let overSection = dragHelpers.copyBlockChildren({ ...prevCopy[overSectionIdx] }); + const subsection = activeSection.childInfo.children[activeSubsectionIdx]; + + overSection = dragHelpers.insertChild(overSection, subsection, newIndex); + + activeSection = dragHelpers.setBlockChildren( + activeSection, + activeSection.childInfo.children.filter((item) => item.id !== subsection.id), + ); + + // eslint-disable-next-line no-param-reassign + prevCopy[activeSectionIdx] = activeSection; + // eslint-disable-next-line no-param-reassign + prevCopy[overSectionIdx] = overSection; + return [prevCopy, overSection.childInfo.children]; +}; + +export const moveUnitOver = ( + prevCopy, + activeSectionIdx, + activeSubsectionIdx, + activeUnitIdx, + overSectionIdx, + overSubsectionIdx, + newIndex, +) => { + const activeSection = dragHelpers.copyBlockChildren({ ...prevCopy[activeSectionIdx] }); + let activeSubsection = dragHelpers.copyBlockChildren( + { ...activeSection.childInfo.children[activeSubsectionIdx] }, + ); + + let overSection = { ...prevCopy[overSectionIdx] }; + if (overSection.id === activeSection.id) { + overSection = activeSection; + } + + overSection = dragHelpers.copyBlockChildren(overSection); + let overSubsection = dragHelpers.copyBlockChildren( + { ...overSection.childInfo.children[overSubsectionIdx] }, + ); + + const unit = activeSubsection.childInfo.children[activeUnitIdx]; + overSubsection = dragHelpers.insertChild(overSubsection, unit, newIndex); + overSection = dragHelpers.setBlockChild(overSection, overSubsection, overSubsectionIdx); + + activeSubsection = dragHelpers.setBlockChildren( + activeSubsection, + activeSubsection.childInfo.children.filter((item) => item.id !== unit.id), + ); + + // eslint-disable-next-line no-param-reassign + prevCopy[activeSectionIdx] = dragHelpers.setBlockChild(activeSection, activeSubsection, activeSubsectionIdx); + // eslint-disable-next-line no-param-reassign + prevCopy[overSectionIdx] = overSection; + return [prevCopy, overSubsection.childInfo.children]; +}; + +export const moveSubsection = ( + prevCopy, + sectionIdx, + currentIdx, + newIdx, +) => { + let section = dragHelpers.copyBlockChildren({ ...prevCopy[sectionIdx] }); + + const result = arrayMove(section.childInfo.children, currentIdx, newIdx); + section = dragHelpers.setBlockChildren(section, result); + + // eslint-disable-next-line no-param-reassign + prevCopy[sectionIdx] = section; + return [prevCopy, result]; +}; + +export const moveUnit = ( + prevCopy, + sectionIdx, + subsectionIdx, + currentIdx, + newIdx, +) => { + let section = dragHelpers.copyBlockChildren({ ...prevCopy[sectionIdx] }); + let subsection = dragHelpers.copyBlockChildren({ ...section.childInfo.children[subsectionIdx] }); + + const result = arrayMove(subsection.childInfo.children, currentIdx, newIdx); + subsection = dragHelpers.setBlockChildren(subsection, result); + section = dragHelpers.setBlockChild(section, subsection, subsectionIdx); + + // eslint-disable-next-line no-param-reassign + prevCopy[sectionIdx] = section; + return [prevCopy, result]; +}; + +/** + * Check if section 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} + */ +export const canMoveSection = (sections) => (id, step) => { + const newId = id + step; + const indexCheck = newId >= 0 && newId < sections.length; + if (!indexCheck) { + return false; + } + const newItem = sections[newId]; + return newItem.actions.draggable; +}; + +export const possibleSubsectionMoves = (sections, sectionIndex, section, subsections) => (index, step) => { + if (!subsections[index]?.actions?.draggable) { + return {}; + } + if ((step === -1 && index >= 1) || (step === 1 && subsections.length - index >= 2)) { + // move subsection inside its own parent section + return { + fn: moveSubsection, + args: [ + sections, + sectionIndex, + index, + index + step, + ], + sectionId: section.id, + }; + } if (step === -1 && index === 0 && sectionIndex > 0) { + // move subsection to last position of previous section + if (!sections[sectionIndex + step]?.actions?.childAddable) { + // return if previous section doesn't allow adding subsections + return {}; + } + return { + fn: moveSubsectionOver, + args: [ + sections, + sectionIndex, + index, + sectionIndex + step, + sections[sectionIndex + step].childInfo.children.length + 1, + ], + sectionId: sections[sectionIndex + step].id, + }; + } if (step === 1 && index === subsections.length - 1 && sectionIndex < sections.length - 1) { + // move subsection to first position of next section + if (!sections[sectionIndex + step]?.actions?.childAddable) { + // return if next section doesn't allow adding subsections + return {}; + } + return { + fn: moveSubsectionOver, + args: [ + sections, + sectionIndex, + index, + sectionIndex + step, + 0, + ], + sectionId: sections[sectionIndex + step].id, + }; + } + return {}; +}; + +export const possibleUnitMoves = ( + sections, + sectionIndex, + subsectionIndex, + section, + subsection, + units, +) => (index, step) => { + if (!units[index].actions.draggable) { + return {}; + } + if ((step === -1 && index >= 1) || (step === 1 && units.length - index >= 2)) { + return { + fn: moveUnit, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + index + step, + ], + sectionId: section.id, + subsectionId: subsection.id, + }; + } if (step === -1 && index === 0) { + if (subsectionIndex > 0) { + // move unit to last position of previous subsection inside same section. + if (!sections[sectionIndex].childInfo.children[subsectionIndex + step]?.actions?.childAddable) { + // return if previous subsection doesn't allow adding subsections + return {}; + } + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + sectionIndex, + subsectionIndex + step, + sections[sectionIndex].childInfo.children[subsectionIndex + step].childInfo.children.length + 1, + ], + sectionId: section.id, + subsectionId: sections[sectionIndex].childInfo.children[subsectionIndex + step].id, + }; + } if (sectionIndex > 0) { + // move unit to last position of previous subsection inside previous section. + const newSectionIndex = sectionIndex + step; + if (sections[newSectionIndex].childInfo.children.length === 0) { + // return if previous section has no subsections. + return {}; + } + const newSubsectionIndex = sections[newSectionIndex].childInfo.children.length - 1; + if (!sections[newSectionIndex].childInfo.children[newSubsectionIndex]?.actions?.childAddable) { + // return if previous subsection doesn't allow adding subsections + return {}; + } + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + newSectionIndex, + newSubsectionIndex, + sections[newSectionIndex].childInfo.children[newSubsectionIndex].childInfo.children.length + 1, + ], + sectionId: sections[newSectionIndex].id, + subsectionId: sections[newSectionIndex].childInfo.children[newSubsectionIndex].id, + }; + } + } else if (step === 1 && index === units.length - 1) { + if (subsectionIndex < sections[sectionIndex].childInfo.children.length - 1) { + // move unit to first position of next subsection inside same section. + if (!sections[sectionIndex].childInfo.children[subsectionIndex + step]?.actions?.childAddable) { + // return if next subsection doesn't allow adding subsections + return {}; + } + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + sectionIndex, + subsectionIndex + step, + 0, + ], + sectionId: section.id, + subsectionId: sections[sectionIndex].childInfo.children[subsectionIndex + step].id, + }; + } if (sectionIndex < sections.length - 1) { + // move unit to first position of next subsection inside next section. + const newSectionIndex = sectionIndex + step; + if (sections[newSectionIndex].childInfo.children.length === 0) { + // return if next section has no subsections. + return {}; + } + const newSubsectionIndex = 0; + if (!sections[newSectionIndex].childInfo.children[newSubsectionIndex]?.actions?.childAddable) { + // return if next subsection doesn't allow adding subsections + return {}; + } + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + newSectionIndex, + newSubsectionIndex, + 0, + ], + sectionId: sections[newSectionIndex].id, + subsectionId: sections[newSectionIndex].childInfo.children[newSubsectionIndex].id, + }; + } + } + return {}; +}; diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index f29312db68..83290de0ac 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -236,24 +236,53 @@ const useCourseOutline = ({ courseId }) => { dispatch(duplicateUnitQuery(currentItem.id, currentSubsection.id, currentSection.id)); }; - const handleSectionDragAndDrop = (sectionListIds, restoreCallback) => { - dispatch(setSectionOrderListQuery(courseId, sectionListIds, restoreCallback)); + const handleVideoSharingOptionChange = (value) => { + dispatch(setVideoSharingOptionQuery(courseId, value)); }; - const handleSubsectionDragAndDrop = (sectionId, subsectionListIds, restoreCallback) => { - dispatch(setSubsectionOrderListQuery(sectionId, subsectionListIds, restoreCallback)); + const handleDismissNotification = () => { + dispatch(dismissNotificationQuery(notificationDismissUrl)); }; - const handleVideoSharingOptionChange = (value) => { - dispatch(setVideoSharingOptionQuery(courseId, value)); + const handleSectionDragAndDrop = ( + sectionListIds, + restoreSectionList, + ) => { + dispatch(setSectionOrderListQuery( + courseId, + sectionListIds, + restoreSectionList, + )); }; - const handleUnitDragAndDrop = (sectionId, subsectionId, unitListIds, restoreCallback) => { - dispatch(setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback)); + const handleSubsectionDragAndDrop = ( + sectionId, + prevSectionId, + subsectionListIds, + restoreSectionList, + ) => { + dispatch(setSubsectionOrderListQuery( + sectionId, + prevSectionId, + subsectionListIds, + restoreSectionList, + )); }; - const handleDismissNotification = () => { - dispatch(dismissNotificationQuery(notificationDismissUrl)); + const handleUnitDragAndDrop = ( + sectionId, + prevSectionId, + subsectionId, + unitListIds, + restoreSectionList, + ) => { + dispatch(setUnitOrderListQuery( + sectionId, + subsectionId, + prevSectionId, + unitListIds, + restoreSectionList, + )); }; useEffect(() => { @@ -317,10 +346,7 @@ const useCourseOutline = ({ courseId }) => { getUnitUrl, openUnitPage, handleNewUnitSubmit, - handleSectionDragAndDrop, - handleSubsectionDragAndDrop, handleVideoSharingOptionChange, - handleUnitDragAndDrop, handleCopyToClipboardClick, handlePasteClipboardClick, notificationDismissUrl, @@ -332,6 +358,9 @@ const useCourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, handleDismissNotification, advanceSettingsUrl, + handleSectionDragAndDrop, + handleSubsectionDragAndDrop, + handleUnitDragAndDrop, }; }; diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index 55c53c710b..342136625a 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; +import { useDispatch, useSelector } from 'react-redux'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ErrorAlert } from '@edx/frontend-lib-content-components'; import { @@ -9,14 +10,18 @@ import { Warning as WarningIcon, } from '@openedx/paragon/icons'; import { Alert, Button, Hyperlink } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; import { RequestStatus } from '../../data/constants'; import AlertMessage from '../../generic/alert-message'; import AlertProctoringError from '../../generic/AlertProctoringError'; import messages from './messages'; import advancedSettingsMessages from '../../advanced-settings/messages'; +import { getPasteFileNotices } from '../data/selectors'; +import { removePasteFileNotices } from '../data/slice'; const PageAlerts = ({ + courseId, notificationDismissUrl, handleDismissNotification, discussionsSettings, @@ -29,9 +34,18 @@ const PageAlerts = ({ savingStatus, }) => { const intl = useIntl(); + const dispatch = useDispatch(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const [showConfigAlert, setShowConfigAlert] = useState(true); const [showDiscussionAlert, setShowDiscussionAlert] = useState(true); + const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices); + + const getAssetsUrl = () => { + if (getConfig().ENABLE_ASSETS_PAGE === 'true') { + return `/course/${courseId}/assets/`; + } + return `${getConfig().STUDIO_BASE_URL}/assets/${courseId}`; + }; const configurationErrors = () => { if (!notificationDismissUrl) { @@ -225,6 +239,97 @@ const PageAlerts = ({ return null; }; + const newFilesPasteAlert = () => { + const onDismiss = () => { + dispatch(removePasteFileNotices(['newFiles'])); + }; + + if (newFiles?.length) { + return ( + + {intl.formatMessage(messages.newFileAlertAction)} + , + ]} + /> + ); + } + return null; + }; + + const errorFilesPasteAlert = () => { + const onDismiss = () => { + dispatch(removePasteFileNotices(['errorFiles'])); + }; + + if (errorFiles?.length) { + return ( + + ); + } + return null; + }; + + const conflictingFilesPasteAlert = () => { + const onDismiss = () => { + dispatch(removePasteFileNotices(['conflictingFiles'])); + }; + + if (conflictingFiles?.length) { + return ( + + {intl.formatMessage(messages.newFileAlertAction)} + , + ]} + /> + ); + } + return null; + }; + return ( <> {configurationErrors()} @@ -234,6 +339,9 @@ const PageAlerts = ({ {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} + {errorFilesPasteAlert()} + {conflictingFilesPasteAlert()} + {newFilesPasteAlert()} ); }; @@ -252,6 +360,7 @@ PageAlerts.defaultProps = { }; PageAlerts.propTypes = { + courseId: PropTypes.string.isRequired, notificationDismissUrl: PropTypes.string, handleDismissNotification: PropTypes.func, discussionsSettings: PropTypes.shape({ diff --git a/src/course-outline/page-alerts/PageAlerts.test.jsx b/src/course-outline/page-alerts/PageAlerts.test.jsx index 944a29a776..c1b18c7042 100644 --- a/src/course-outline/page-alerts/PageAlerts.test.jsx +++ b/src/course-outline/page-alerts/PageAlerts.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import { act, render, fireEvent } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -15,10 +16,16 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ }), })); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + let store; const handleDismissNotification = jest.fn(); const pageAlertsData = { + courseId: 'course-id', notificationDismissUrl: '', handleDismissNotification: null, discussionsSettings: {}, @@ -53,6 +60,7 @@ describe('', () => { }, }); store = initializeStore(); + useSelector.mockReturnValue({}); }); it('renders null when no alerts are present', () => { @@ -152,4 +160,33 @@ describe('', () => { `${getConfig().STUDIO_BASE_URL}/some-url`, ); }); + + it('renders new & error files alert', async () => { + useSelector.mockReturnValue({ + newFiles: ['periodic-table.css'], + conflictingFiles: [], + errorFiles: ['error.css'], + }); + const { queryByText } = renderComponent(); + expect(queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument(); + expect(queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument(); + expect(queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/assets/course-id`, + ); + }); + + it('renders conflicting files alert', async () => { + useSelector.mockReturnValue({ + newFiles: [], + conflictingFiles: ['some.css', 'some.js'], + errorFiles: [], + }); + const { queryByText } = renderComponent(); + expect(queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument(); + expect(queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/assets/course-id`, + ); + }); }); diff --git a/src/course-outline/page-alerts/messages.js b/src/course-outline/page-alerts/messages.js index 5964dbdf14..31e6d12c78 100644 --- a/src/course-outline/page-alerts/messages.js +++ b/src/course-outline/page-alerts/messages.js @@ -4,58 +4,107 @@ const messages = defineMessages({ configurationErrorTitle: { id: 'course-authoring.course-outline.page-alerts.configurationErrorTitle', defaultMessage: 'This course was created as a re-run. Some manual configuration is needed.', + description: 'Configuration error alert title in course outline.', }, configurationErrorText: { id: 'course-authoring.course-outline.page-alerts.configurationErrorText', defaultMessage: 'No course content is currently visible, and no learners are enrolled. Be sure to review and reset all dates, including the Course Start Date; set up the course team; review course updates and other assets for dated material; and seed the discussions and wiki.', + description: 'Configuration error alert body in course outline.', }, discussionNotificationText: { id: 'course-authoring.course-outline.page-alerts.discussionNotificationText', defaultMessage: 'This course run is using an upgraded version of {platformName} discussion forum. In order to display the discussions sidebar, discussions xBlocks will no longer be visible to learners.', + description: 'Alert text for informing users about upgraded version of discussions forum.', }, discussionNotificationLearnMore: { id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore', defaultMessage: 'Learn more', + description: 'Learn more link in upgraded discussion notification alert', }, discussionNotificationFeedback: { id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore', defaultMessage: 'Share feedback', + description: 'Share feedback link in upgraded discussion notification alert', }, deprecationWarningTitle: { id: 'course-authoring.course-outline.page-alerts.deprecationWarningTitle', defaultMessage: 'This course uses features that are no longer supported.', + description: 'Alert title informing users about deprecated features being used in course that are not supported.', }, deprecationWarningBlocksText: { id: 'course-authoring.course-outline.page-alerts.deprecationWarningBlocksText', defaultMessage: 'You must delete or replace the following components.', + description: 'Alert body text informing users about deprecated components which needs to be removed or replaced.', }, deprecationWarningDeprecatedBlockText: { id: 'course-authoring.course-outline.page-alerts.deprecationWarningDeprecatedBlockText', defaultMessage: 'To avoid errors, {platformName} strongly recommends that you remove unsupported features from the course advanced settings. To do this, go to the {hyperlink}, locate the "Advanced Module List" setting, and then delete the following modules from the list.', + description: 'Alert body text informing users about how to remove deprecated components/modules.', }, advancedSettingLinkText: { id: 'course-authoring.course-outline.page-alerts.advancedSettingLinkText', defaultMessage: 'Advanced Settings page', + description: 'Advanced settings page link text', }, deprecatedComponentName: { id: 'course-authoring.course-outline.page-alerts.deprecatedComponentName', defaultMessage: 'Deprecated Component', + description: 'Default name for a deprecated component.', }, proctoringErrorTitle: { id: 'course-authoring.course-outline.page-alerts.proctoringErrorTitle', defaultMessage: 'This course has proctored exam settings that are incomplete or invalid.', + description: 'Proctoring settings errors alert title.', }, proctoringErrorText: { id: 'course-authoring.course-outline.page-alerts.proctoringErrorText', defaultMessage: 'To update these settings go to the {hyperlink}.', + description: 'Proctoring settings errors alert body text.', }, proctoredSettingsLinkText: { id: 'course-authoring.course-outline.page-alerts.proctoredSettingsLinkText', defaultMessage: 'Proctored Exam Settings page', + description: 'Proctoring settings page link text.', }, alertFailedGeneric: { id: 'course-authoring.course-outline.page-alert.generic-error.description', defaultMessage: 'Unable to {actionName} {type}. Please try again.', + description: 'Generic alert text.', + }, + newFileAlertTitle: { + id: 'course-authoring.course-outline.page-alert.paste-alert.new-files.title', + defaultMessage: 'New {newFilesLen, plural, one {file} other {files}} added to Files.', + description: 'This title is displayed when new files are successfully imported into the course after pasting an unit.', + }, + newFileAlertDesc: { + id: 'course-authoring.course-outline.page-alert.paste-alert.new-files.description', + defaultMessage: 'The following required {newFilesLen, plural, one {file was} other {files were}} imported to this course: {newFilesStr}', + description: 'This description is displayed when new files are successfully imported into the course after pasting an unit', + }, + newFileAlertAction: { + id: 'course-authoring.course-outline.page-alert.paste-alert.new-files.action', + defaultMessage: 'View files', + description: 'This label is used as the text for a button that allows the user to view the imported files.', + }, + errorFileAlertTitle: { + id: 'course-authoring.course-outline.page-alert.paste-alert.error-files.title', + defaultMessage: 'Some errors occurred', + description: 'This title is displayed when there are errors during the import of files while pasting an unit.', + }, + errorFileAlertDesc: { + id: 'course-authoring.course-outline.page-alert.paste-alert.error-files.description', + defaultMessage: 'The following required {errorFilesLen, plural, one {file} other {files}} could not be added to the course: {errorFilesStr}', + description: 'This description is displayed when there are errors during the import of files and lists the files that could not be imported.', + }, + conflictingFileAlertTitle: { + id: 'course-authoring.course-outline.page-alert.paste-alert.conflicting-files.title', + defaultMessage: 'You may need to update {conflictingFilesLen, plural, one {a file} other {files}} manually', + description: 'This alert title is displayed when files being imported conflict with existing files in the course.', + }, + conflictingFileAlertDesc: { + id: 'course-authoring.course-outline.page-alert.paste-alert.new-conflicting.description', + defaultMessage: 'The following {conflictingFilesLen, plural, one {file} other {files}} already exist in this course but don\'t match the version used by the component you pasted: {conflictingFilesStr}', + description: 'This alert description is displayed when files being imported conflict with existing files in the course and advises the user to update the conflicting files manually.', }, }); diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 59db412210..6e3f617f7c 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -1,5 +1,5 @@ import React, { - useEffect, useState, useRef, + useContext, useEffect, useState, useRef, } from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; @@ -11,7 +11,8 @@ import classNames from 'classnames'; import { setCurrentItem, setCurrentSection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; -import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; +import SortableItem from '../drag-helper/SortableItem'; +import { DragContext } from '../drag-helper/DragContextProvider'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; @@ -38,6 +39,7 @@ const SectionCard = ({ const currentRef = useRef(null); const intl = useIntl(); const dispatch = useDispatch(); + const { activeId, overId } = useContext(DragContext); const [isExpanded, setIsExpanded] = useState(isSectionsExpanded); const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'section'; @@ -46,15 +48,9 @@ const SectionCard = ({ setIsExpanded(isSectionsExpanded); }, [isSectionsExpanded]); - useEffect(() => { - // if this items has been newly added, scroll to it. - if (currentRef.current && section.shouldScroll) { - scrollToElement(currentRef.current); - } - }, []); - const { id, + category, displayName, hasChanges, published, @@ -64,6 +60,21 @@ const SectionCard = ({ isHeaderVisible = true, } = section; + useEffect(() => { + if (activeId === id && isExpanded) { + setIsExpanded(false); + } else if (overId === id && !isExpanded) { + setIsExpanded(true); + } + }, [activeId, overId]); + + useEffect(() => { + // if this items has been newly added, scroll to it. + if (currentRef.current && section.shouldScroll) { + scrollToElement(currentRef.current); + } + }, []); + // re-create actions object for customizations const actions = { ...sectionActions }; // add actions to control display of move up & down menu buton. @@ -132,9 +143,11 @@ const SectionCard = ({ const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); return ( -
- + ); }; @@ -223,6 +236,7 @@ SectionCard.propTypes = { section: PropTypes.shape({ id: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired, + category: PropTypes.string.isRequired, published: PropTypes.bool.isRequired, hasChanges: PropTypes.bool.isRequired, visibilityState: PropTypes.string.isRequired, diff --git a/src/course-outline/section-card/SectionCard.test.jsx b/src/course-outline/section-card/SectionCard.test.jsx index 26094911d5..cea2a0fa6b 100644 --- a/src/course-outline/section-card/SectionCard.test.jsx +++ b/src/course-outline/section-card/SectionCard.test.jsx @@ -18,6 +18,7 @@ let store; const section = { id: '123', displayName: 'Section Name', + category: 'chapter', published: true, visibilityState: 'live', hasChanges: false, @@ -38,17 +39,20 @@ const renderComponent = (props) => render( children diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index ec06ac360c..8019042e5b 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -1,4 +1,6 @@ -import { useEffect, useState, useRef } from 'react'; +import { + useContext, useEffect, useState, useRef, +} from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import { useSearchParams } from 'react-router-dom'; @@ -6,12 +8,14 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, useToggle } from '@openedx/paragon'; import { Add as IconAdd } from '@openedx/paragon/icons'; import classNames from 'classnames'; +import { isEmpty } from 'lodash'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import { COURSE_BLOCK_NAMES } from '../constants'; import CardHeader from '../card-header/CardHeader'; -import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; +import SortableItem from '../drag-helper/SortableItem'; +import { DragContext } from '../drag-helper/DragContextProvider'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; import PasteButton from '../paste-button/PasteButton'; @@ -25,7 +29,7 @@ const SubsectionCard = ({ isCustomRelativeDatesActive, children, index, - canMoveItem, + getPossibleMoves, onOpenPublishModal, onEditSubmit, savingStatus, @@ -39,6 +43,7 @@ const SubsectionCard = ({ const currentRef = useRef(null); const intl = useIntl(); const dispatch = useDispatch(); + const { activeId, overId } = useContext(DragContext); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === subsection.id; @@ -47,6 +52,7 @@ const SubsectionCard = ({ const { id, + category, displayName, hasChanges, published, @@ -60,8 +66,10 @@ const SubsectionCard = ({ // re-create actions object for customizations const actions = { ...subsectionActions }; // add actions to control display of move up & down menu buton. - actions.allowMoveUp = canMoveItem(index, -1); - actions.allowMoveDown = canMoveItem(index, 1); + const moveUpDetails = getPossibleMoves(index, -1); + const moveDownDetails = getPossibleMoves(index, 1); + actions.allowMoveUp = !isEmpty(moveUpDetails); + actions.allowMoveDown = !isEmpty(moveDownDetails); const [isExpanded, setIsExpanded] = useState(locatorId ? isScrolledToElement : !isHeaderVisible); const subsectionStatus = getItemStatus({ @@ -91,11 +99,11 @@ const SubsectionCard = ({ }; const handleSubsectionMoveUp = () => { - onOrderChange(index, index - 1); + onOrderChange(section, moveUpDetails); }; const handleSubsectionMoveDown = () => { - onOrderChange(index, index + 1); + onOrderChange(section, moveDownDetails); }; const handleNewButtonClick = () => onNewUnitSubmit(id); @@ -110,6 +118,14 @@ const SubsectionCard = ({ /> ); + useEffect(() => { + if (activeId === id && isExpanded) { + setIsExpanded(false); + } else if (overId === id && !isExpanded) { + setIsExpanded(true); + } + }, [activeId, overId]); + useEffect(() => { // if this items has been newly added, scroll to it. // we need to check section.shouldScroll as whole section is fetched when a @@ -132,10 +148,12 @@ const SubsectionCard = ({ ); return ( - )} - + ); }; @@ -225,6 +243,7 @@ SubsectionCard.propTypes = { subsection: PropTypes.shape({ id: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired, + category: PropTypes.string.isRequired, published: PropTypes.bool.isRequired, hasChanges: PropTypes.bool.isRequired, visibilityState: PropTypes.string.isRequired, @@ -249,7 +268,7 @@ SubsectionCard.propTypes = { onDuplicateSubmit: PropTypes.func.isRequired, onNewUnitSubmit: PropTypes.func.isRequired, index: PropTypes.number.isRequired, - canMoveItem: PropTypes.func.isRequired, + getPossibleMoves: PropTypes.func.isRequired, onOrderChange: PropTypes.func.isRequired, onOpenConfigureModal: PropTypes.func.isRequired, onPasteClick: PropTypes.func.isRequired, diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx index 17985f68d8..d85fc9ecf7 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.jsx @@ -37,6 +37,7 @@ const section = { const subsection = { id: '123', displayName: 'Subsection Name', + category: 'sequential', published: true, visibilityState: 'live', hasChanges: false, @@ -47,6 +48,7 @@ const subsection = { duplicable: true, }, isHeaderVisible: true, + releasedToStudents: true, }; const onEditSubectionSubmit = jest.fn(); @@ -58,17 +60,22 @@ const renderComponent = (props, entry = '/') => render( children @@ -204,6 +211,7 @@ describe('', () => { ...subsection, published: false, visibilityState: 'needs_attention', + hasChanges: true, }, }); expect(await findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 3c4f5ef8a8..11fe255bde 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -2,11 +2,12 @@ import { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import { useToggle, Sheet } from '@openedx/paragon'; +import { isEmpty } from 'lodash'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; -import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; +import SortableItem from '../drag-helper/SortableItem'; import TitleLink from '../card-header/TitleLink'; import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; @@ -19,7 +20,7 @@ const UnitCard = ({ isSelfPaced, isCustomRelativeDatesActive, index, - canMoveItem, + getPossibleMoves, onOpenPublishModal, onOpenConfigureModal, onEditSubmit, @@ -40,6 +41,7 @@ const UnitCard = ({ const { id, + category, displayName, hasChanges, published, @@ -53,8 +55,10 @@ const UnitCard = ({ // re-create actions object for customizations const actions = { ...unitActions }; // add actions to control display of move up & down menu buton. - actions.allowMoveUp = canMoveItem(index, -1); - actions.allowMoveDown = canMoveItem(index, 1); + const moveUpDetails = getPossibleMoves(index, -1); + const moveDownDetails = getPossibleMoves(index, 1); + actions.allowMoveUp = !isEmpty(moveUpDetails); + actions.allowMoveDown = !isEmpty(moveDownDetails); const parentInfo = { graded: subsection.graded, @@ -84,11 +88,11 @@ const UnitCard = ({ }; const handleUnitMoveUp = () => { - onOrderChange(index, index - 1); + onOrderChange(section, moveUpDetails); }; const handleUnitMoveDown = () => { - onOrderChange(index, index + 1); + onOrderChange(section, moveDownDetails); }; const handleCopyClick = () => { @@ -126,10 +130,12 @@ const UnitCard = ({ return ( <> - - + render( section={section} subsection={subsection} unit={unit} - index="1" - canMoveItem={jest.fn()} + index={1} + getPossibleMoves={jest.fn()} onOrderChange={jest.fn()} onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} + onOpenConfigureModal={jest.fn()} + onCopyToClipboardClick={jest.fn()} savingStatus="" onEditSubmit={jest.fn()} onDuplicateSubmit={jest.fn()} getTitleLink={(id) => `/some/${id}`} + isSelfPaced={false} + isCustomRelativeDatesActive={false} {...props} /> , @@ -133,4 +138,15 @@ describe('', () => { await act(async () => fireEvent.click(menu)); expect(within(element).queryByText(cardMessages.menuCopy.defaultMessage)).toBeInTheDocument(); }); + + it('hides status badge for unscheduled units', async () => { + const { queryByRole } = renderComponent({ + unit: { + ...unit, + visibilityState: 'unscheduled', + hasChanges: false, + }, + }); + expect(queryByRole('status')).not.toBeInTheDocument(); + }); }); diff --git a/src/course-outline/utils.jsx b/src/course-outline/utils.jsx index fc2aac8355..c946ae3577 100644 --- a/src/course-outline/utils.jsx +++ b/src/course-outline/utils.jsx @@ -1,9 +1,9 @@ import { CheckCircle as CheckCircleIcon, Lock as LockIcon, - EditOutline as EditOutlineIcon, } from '@openedx/paragon/icons'; +import DraftIcon from '../generic/DraftIcon'; import { ITEM_BADGE_STATUS, VIDEO_SHARING_OPTIONS } from './constants'; import { VisibilityTypes } from '../data/constants'; @@ -25,6 +25,8 @@ const getItemStatus = ({ return ITEM_BADGE_STATUS.gated; case visibilityState === VisibilityTypes.LIVE: return ITEM_BADGE_STATUS.live; + case visibilityState === VisibilityTypes.UNSCHEDULED: + return ITEM_BADGE_STATUS.unscheduled; case published && !hasChanges: return ITEM_BADGE_STATUS.publishedNotLive; case published && hasChanges: @@ -57,7 +59,7 @@ const getItemStatusBadgeContent = (status, messages, intl) => { case ITEM_BADGE_STATUS.publishedNotLive: return { badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive), - badgeIcon: '', + badgeIcon: null, }; case ITEM_BADGE_STATUS.staffOnly: return { @@ -67,17 +69,17 @@ const getItemStatusBadgeContent = (status, messages, intl) => { case ITEM_BADGE_STATUS.unpublishedChanges: return { badgeTitle: intl.formatMessage(messages.statusBadgeUnpublishedChanges), - badgeIcon: EditOutlineIcon, + badgeIcon: DraftIcon, }; case ITEM_BADGE_STATUS.draft: return { badgeTitle: intl.formatMessage(messages.statusBadgeDraft), - badgeIcon: EditOutlineIcon, + badgeIcon: DraftIcon, }; default: return { badgeTitle: '', - badgeIcon: '', + badgeIcon: null, }; } }; @@ -115,6 +117,10 @@ const getItemStatusBorder = (status) => { return { borderLeft: '5px solid #F0CC00', }; + case ITEM_BADGE_STATUS.unscheduled: + return { + borderLeft: '5px solid #ccc', + }; default: return {}; } diff --git a/src/course-outline/xblock-status/GradingPolicyAlert.jsx b/src/course-outline/xblock-status/GradingPolicyAlert.jsx index 4d619c536b..1aec5c790e 100644 --- a/src/course-outline/xblock-status/GradingPolicyAlert.jsx +++ b/src/course-outline/xblock-status/GradingPolicyAlert.jsx @@ -37,12 +37,13 @@ const GradingPolicyAlert = ({ GradingPolicyAlert.defaultProps = { graded: false, gradingType: '', + courseGraders: [], }; GradingPolicyAlert.propTypes = { graded: PropTypes.bool, gradingType: PropTypes.string, - courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired), }; export default GradingPolicyAlert; diff --git a/src/course-outline/xblock-status/ReleaseStatus.jsx b/src/course-outline/xblock-status/ReleaseStatus.jsx index ffba129eea..59cbb50596 100644 --- a/src/course-outline/xblock-status/ReleaseStatus.jsx +++ b/src/course-outline/xblock-status/ReleaseStatus.jsx @@ -53,13 +53,15 @@ const ReleaseStatus = ({ ReleaseStatus.defaultProps = { explanatoryMessage: '', + releaseDate: '', + releasedToStudents: false, }; ReleaseStatus.propTypes = { isInstructorPaced: PropTypes.bool.isRequired, explanatoryMessage: PropTypes.string, - releaseDate: PropTypes.string.isRequired, - releasedToStudents: PropTypes.bool.isRequired, + releaseDate: PropTypes.string, + releasedToStudents: PropTypes.bool, }; export default ReleaseStatus; diff --git a/src/course-outline/xblock-status/StatusMessages.jsx b/src/course-outline/xblock-status/StatusMessages.jsx index 609678fd75..dd0fc53909 100644 --- a/src/course-outline/xblock-status/StatusMessages.jsx +++ b/src/course-outline/xblock-status/StatusMessages.jsx @@ -68,6 +68,7 @@ StatusMessages.defaultProps = { prereq: '', prereqs: [], userPartitionInfo: {}, + hasPartitionGroupComponents: false, }; StatusMessages.propTypes = { @@ -79,10 +80,10 @@ StatusMessages.propTypes = { blockDisplayName: PropTypes.string.isRequired, })), userPartitionInfo: PropTypes.shape({ - selectedPartitionIndex: PropTypes.number.isRequired, - selectedGroupsLabel: PropTypes.string.isRequired, + selectedPartitionIndex: PropTypes.number, + selectedGroupsLabel: PropTypes.string, }), - hasPartitionGroupComponents: PropTypes.bool.isRequired, + hasPartitionGroupComponents: PropTypes.bool, }; export default StatusMessages; diff --git a/src/course-outline/xblock-status/XBlockStatus.jsx b/src/course-outline/xblock-status/XBlockStatus.jsx index 4073d3a9de..d077889e71 100644 --- a/src/course-outline/xblock-status/XBlockStatus.jsx +++ b/src/course-outline/xblock-status/XBlockStatus.jsx @@ -93,8 +93,8 @@ XBlockStatus.propTypes = { blockData: PropTypes.shape({ category: PropTypes.string.isRequired, explanatoryMessage: PropTypes.string, - releasedToStudents: PropTypes.bool.isRequired, - releaseDate: PropTypes.string.isRequired, + releasedToStudents: PropTypes.bool, + releaseDate: PropTypes.string, isProctoredExam: PropTypes.bool, isOnboardingExam: PropTypes.bool, isPracticeExam: PropTypes.bool, @@ -105,16 +105,16 @@ XBlockStatus.propTypes = { })), staffOnlyMessage: PropTypes.bool, userPartitionInfo: PropTypes.shape({ - selectedPartitionIndex: PropTypes.number.isRequired, - selectedGroupsLabel: PropTypes.string.isRequired, + selectedPartitionIndex: PropTypes.number, + selectedGroupsLabel: PropTypes.string, }), - hasPartitionGroupComponents: PropTypes.bool.isRequired, + hasPartitionGroupComponents: PropTypes.bool, format: PropTypes.string, dueDate: PropTypes.string, relativeWeeksDue: PropTypes.number, isTimeLimited: PropTypes.bool, graded: PropTypes.bool, - courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired), hideAfterDue: PropTypes.bool, }).isRequired, }; diff --git a/src/data/constants.js b/src/data/constants.js index 2a630af9a6..65c330ef6d 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -50,4 +50,6 @@ export const VisibilityTypes = /** @type {const} */ ({ LIVE: 'live', STAFF_ONLY: 'staff_only', HIDE_AFTER_DUE: 'hide_after_due', + UNSCHEDULED: 'unscheduled', + NEEDS_ATTENTION: 'needs_attention', }); diff --git a/src/generic/DraftIcon.jsx b/src/generic/DraftIcon.jsx new file mode 100644 index 0000000000..6b2b9d95f1 --- /dev/null +++ b/src/generic/DraftIcon.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +const DraftIcon = (props) => ( + + + +); + +export default DraftIcon; diff --git a/src/index.jsx b/src/index.jsx index 725cdea0b5..7a9d890576 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -122,6 +122,7 @@ initialize({ NOTIFICATION_FEEDBACK_URL: process.env.NOTIFICATION_FEEDBACK_URL || null, ENABLE_NEW_EDITOR_PAGES: process.env.ENABLE_NEW_EDITOR_PAGES || 'false', ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false', + ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false', ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false', ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',