feat: allow dragging blocks across parents in outline (#859)
This commit is contained in:
1
.env
1
.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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) => {
|
||||
<Container size="xl" className="px-4">
|
||||
<section className="course-outline-container mb-4 mt-5">
|
||||
<PageAlerts
|
||||
courseId={courseId}
|
||||
notificationDismissUrl={notificationDismissUrl}
|
||||
handleDismissNotification={handleDismissNotification}
|
||||
discussionsSettings={discussionsSettings}
|
||||
@@ -347,95 +303,111 @@ const CourseOutline = ({ courseId }) => {
|
||||
<div className="pt-4">
|
||||
{sections.length ? (
|
||||
<>
|
||||
<DraggableList itemList={sections} setState={setSections} updateOrder={finalizeSectionOrder}>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<SectionCard
|
||||
id={section.id}
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={sectionIndex}
|
||||
canMoveItem={canMoveItem(sections)}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
>
|
||||
<DraggableList
|
||||
itemList={section.childInfo.children}
|
||||
setState={setSubsection(sectionIndex)}
|
||||
updateOrder={finalizeSubsectionOrder(section)}
|
||||
<DraggableList
|
||||
items={sections}
|
||||
setSections={setSections}
|
||||
restoreSectionList={restoreSectionList}
|
||||
prevContainerInfo={prevContainerInfo}
|
||||
handleSectionDragAndDrop={handleSectionDragAndDrop}
|
||||
handleSubsectionDragAndDrop={handleSubsectionDragAndDrop}
|
||||
handleUnitDragAndDrop={handleUnitDragAndDrop}
|
||||
>
|
||||
<SortableContext
|
||||
id="root"
|
||||
items={sections}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<SectionCard
|
||||
id={section.id}
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={sectionIndex}
|
||||
canMoveItem={canMoveSection(sections)}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
>
|
||||
{section.childInfo.children.map((subsection, subsectionIndex) => (
|
||||
<SubsectionCard
|
||||
key={subsection.id}
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={subsectionIndex}
|
||||
canMoveItem={canMoveItem(section.childInfo.children)}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
onOrderChange={updateSubsectionOrderByIndex(
|
||||
sectionIndex,
|
||||
section,
|
||||
section.childInfo.children,
|
||||
)}
|
||||
onPasteClick={handlePasteClipboardClick}
|
||||
>
|
||||
<DraggableList
|
||||
itemList={subsection.childInfo.children}
|
||||
setState={setUnit(sectionIndex, subsectionIndex)}
|
||||
updateOrder={finalizeUnitOrder(section, subsection)}
|
||||
<SortableContext
|
||||
id={section.id}
|
||||
items={section.childInfo.children}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{section.childInfo.children.map((subsection, subsectionIndex) => (
|
||||
<SubsectionCard
|
||||
key={subsection.id}
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={subsectionIndex}
|
||||
getPossibleMoves={possibleSubsectionMoves(
|
||||
[...sections],
|
||||
sectionIndex,
|
||||
section,
|
||||
section.childInfo.children,
|
||||
)}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
onOrderChange={updateSubsectionOrderByIndex}
|
||||
onPasteClick={handlePasteClipboardClick}
|
||||
>
|
||||
{subsection.childInfo.children.map((unit, unitIndex) => (
|
||||
<UnitCard
|
||||
key={unit.id}
|
||||
unit={unit}
|
||||
subsection={subsection}
|
||||
section={section}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
index={unitIndex}
|
||||
canMoveItem={canMoveItem(subsection.childInfo.children)}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
onOrderChange={updateUnitOrderByIndex(
|
||||
sectionIndex,
|
||||
subsectionIndex,
|
||||
section,
|
||||
subsection,
|
||||
subsection.childInfo.children,
|
||||
)}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
tagsCount={isUnitsTagCountsLoaded ? unitsTagCounts[unit.id] : 0}
|
||||
/>
|
||||
))}
|
||||
</DraggableList>
|
||||
</SubsectionCard>
|
||||
))}
|
||||
</DraggableList>
|
||||
</SectionCard>
|
||||
))}
|
||||
<SortableContext
|
||||
id={subsection.id}
|
||||
items={subsection.childInfo.children}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{subsection.childInfo.children.map((unit, unitIndex) => (
|
||||
<UnitCard
|
||||
key={unit.id}
|
||||
unit={unit}
|
||||
subsection={subsection}
|
||||
section={section}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
index={unitIndex}
|
||||
getPossibleMoves={possibleUnitMoves(
|
||||
[...sections],
|
||||
sectionIndex,
|
||||
subsectionIndex,
|
||||
section,
|
||||
subsection,
|
||||
subsection.childInfo.children,
|
||||
)}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
onOrderChange={updateUnitOrderByIndex}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
tagsCount={isUnitsTagCountsLoaded ? unitsTagCounts[unit.id] : 0}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</SubsectionCard>
|
||||
))}
|
||||
</SortableContext>
|
||||
</SectionCard>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DraggableList>
|
||||
{courseActions.childAddable && (
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
@import "./highlights-modal/HighlightsModal";
|
||||
@import "./publish-modal/PublishModal";
|
||||
@import "./configure-modal/ConfigureModal";
|
||||
@import "./drag-helper/ConditionalSortableElement";
|
||||
@import "./drag-helper/SortableItem";
|
||||
@import "./xblock-status/XBlockStatus";
|
||||
@import "./paste-button/PasteButton";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { closestCorners } from '@dnd-kit/core';
|
||||
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
@@ -47,6 +48,12 @@ import configureModalMessages from './configure-modal/messages';
|
||||
import pasteButtonMessages from './paste-button/messages';
|
||||
import subsectionMessages from './subsection-card/messages';
|
||||
import pageAlertMessages from './page-alerts/messages';
|
||||
import {
|
||||
moveSubsectionOver,
|
||||
moveUnitOver,
|
||||
moveSubsection,
|
||||
moveUnit,
|
||||
} from './drag-helper/utils';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -83,6 +90,18 @@ jest.mock('./data/apiHooks', () => () => ({
|
||||
isSuccess: true,
|
||||
}));
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
// Since jsdom (used by jest) does not support getBoundingClientRect function
|
||||
// which is required for drag-n-drop calculations, we mock closestCorners fn
|
||||
// from dnd-kit to return collided elements as per the test. This allows us to
|
||||
// test all drag-n-drop handlers.
|
||||
closestCorners: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
@@ -194,6 +213,56 @@ describe('<CourseOutline />', () => {
|
||||
expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check that new section list is saved when dragged', async () => {
|
||||
const { findAllByRole } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = sectionsDraggers[6];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
closestCorners.mockReturnValue([{ id: section1 }]);
|
||||
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await waitFor(async () => {
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
const section2 = store.getState().courseOutline.sectionsList[1].id;
|
||||
expect(section1).toBe(section2);
|
||||
});
|
||||
|
||||
it('check section list is restored to original order when API call fails', async () => {
|
||||
const { findAllByRole } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = sectionsDraggers[6];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(500);
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
closestCorners.mockReturnValue([{ id: section1 }]);
|
||||
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await waitFor(async () => {
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
|
||||
const section1New = store.getState().courseOutline.sectionsList[0].id;
|
||||
expect(section1).toBe(section1New);
|
||||
});
|
||||
|
||||
it('adds new section correctly', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
let elements = await findAllByTestId('section-card');
|
||||
@@ -591,6 +660,7 @@ describe('<CourseOutline />', () => {
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
},
|
||||
...section.childInfo.children.slice(1),
|
||||
],
|
||||
@@ -608,6 +678,7 @@ describe('<CourseOutline />', () => {
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
},
|
||||
...section.childInfo.children[0].childInfo.children.slice(1),
|
||||
],
|
||||
@@ -632,7 +703,7 @@ describe('<CourseOutline />', () => {
|
||||
expect(
|
||||
(await within(element).getAllByRole('status'))[0],
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage);
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgeLive.defaultMessage);
|
||||
};
|
||||
|
||||
// publish unit, subsection and then section in order.
|
||||
@@ -1468,6 +1539,12 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[0].id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveSubsection([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 0, 0, 1)[0][0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
|
||||
@@ -1480,23 +1557,98 @@ describe('<CourseOutline />', () => {
|
||||
expect(secondSubsection.id).toBe(firstSubsectionId);
|
||||
|
||||
// move first section back to second position to test move down option
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
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 to prev section if it is on top of its parent section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const [firstSection, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(firstSection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveSubsectionOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 1, 0, 0, firstSection.childInfo.children.length + 1)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(firstSection.id))
|
||||
.reply(200, expectedSections[0]);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
|
||||
// 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 first subsection in second section to last position of prev section
|
||||
const moveUpButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstSectionSubsections = store.getState().courseOutline.sectionsList[0].childInfo.children;
|
||||
expect(firstSectionSubsections.length).toBe(firstSection.childInfo.children.length + 1);
|
||||
const lastSubsectionFirstSection = firstSectionSubsections[firstSectionSubsections.length - 1].id;
|
||||
expect(subsection.id).toBe(lastSubsectionFirstSection);
|
||||
const subsectionsSecondSection = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||
expect(subsectionsSecondSection.length).toBe(section.childInfo.children.length - 1);
|
||||
});
|
||||
|
||||
it('check whether subsection move down to next section if it is in bottom position of its parent section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const [section, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const lastSubsectionIdx = section.childInfo.children.length - 1;
|
||||
const subsection = section.childInfo.children[lastSubsectionIdx];
|
||||
const subsectionElement = (await within(sectionElement).findAllByTestId('subsection-card'))[lastSubsectionIdx];
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(secondSection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveSubsectionOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 0, lastSubsectionIdx, 1, 0)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSections[0]);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(secondSection.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
|
||||
// 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 first subsection in second section to last position of prev section
|
||||
const moveDownBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownBtn));
|
||||
const firstSectionSubsections = store.getState().courseOutline.sectionsList[0].childInfo.children;
|
||||
expect(firstSectionSubsections.length).toBe(section.childInfo.children.length - 1);
|
||||
const subsectionsSecondSection = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||
expect(subsectionsSecondSection.length).toBe(secondSection.childInfo.children.length + 1);
|
||||
const firstSubSecondSection = subsectionsSecondSection[0].id;
|
||||
expect(subsection.id).toBe(firstSubSecondSection);
|
||||
});
|
||||
|
||||
it('check whether subsection move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// using second section as second section in mock has 3 subsections
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
// using first section
|
||||
const sectionElements = await findAllByTestId('section-card');
|
||||
const firstSectionElement = sectionElements[0];
|
||||
// get first, second and last subsection element
|
||||
const {
|
||||
0: firstSubsection,
|
||||
1: secondSubsection,
|
||||
length,
|
||||
[length - 1]: lastSubsection,
|
||||
} = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const [
|
||||
firstSubsection,
|
||||
secondSubsection,
|
||||
] = await within(firstSectionElement).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');
|
||||
@@ -1521,10 +1673,13 @@ describe('<CourseOutline />', () => {
|
||||
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
|
||||
const lastSectionElement = sectionElements[sectionElements.length - 1];
|
||||
// get first, second and last subsection element
|
||||
const [lastSubsection] = await within(lastSectionElement).findAllByTestId('subsection-card');
|
||||
// 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
|
||||
// move down option should not be enabled in last subsection of last section element
|
||||
expect(
|
||||
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
@@ -1550,6 +1705,10 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveUnit([...courseOutlineIndexMock.courseStructure.childInfo.children], 1, 1, 0, 1)[0][1];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||
@@ -1562,111 +1721,229 @@ describe('<CourseOutline />', () => {
|
||||
expect(secondUnit.id).toBe(firstUnitId);
|
||||
|
||||
// move first unit back to second position to test move down option
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
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 () => {
|
||||
it('check whether unit moves up to previous subsection if it is in top position in parent subsection', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// using second section -> second subsection as it has 5 units in mock.
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [firstSubsection, 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));
|
||||
// get first, second and last unit element
|
||||
const {
|
||||
0: firstUnit,
|
||||
1: secondUnit,
|
||||
length,
|
||||
[length - 1]: lastUnit,
|
||||
} = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(firstSubsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 1, 1, 0, 1, 0, firstSubsection.childInfo.children.length)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
|
||||
// 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 first unit to last position of prev subsection
|
||||
const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstSubUnits = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children;
|
||||
expect(firstSubUnits[firstSubUnits.length - 1].id).toBe(unit.id);
|
||||
const secondSubUnits = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children;
|
||||
expect(secondSubUnits.length).toBe(subsection.childInfo.children.length - 1);
|
||||
});
|
||||
|
||||
it('check whether unit moves up to previous subsection of prev section if it is in top position in parent subsection & section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [firstSection, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = secondSection.childInfo.children;
|
||||
const firstSectionLastSubsection = firstSection.childInfo.children[firstSection.childInfo.children.length - 1];
|
||||
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 [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(firstSectionLastSubsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver(
|
||||
[...courseOutlineIndexMock.courseStructure.childInfo.children],
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
firstSection.childInfo.children.length - 1,
|
||||
firstSectionLastSubsection.childInfo.children.length,
|
||||
)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(firstSection.id))
|
||||
.reply(200, expectedSections[0]);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(secondSection.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
|
||||
// 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 first unit to last position of prev subsection
|
||||
const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstSectionSubStore = store.getState().courseOutline.sectionsList[0].childInfo.children;
|
||||
const firstSectionLastSubUnits = firstSectionSubStore[firstSectionSubStore.length - 1].childInfo.children;
|
||||
expect(firstSectionLastSubUnits[firstSectionLastSubUnits.length - 1].id).toBe(unit.id);
|
||||
const secondSubUnits = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children;
|
||||
expect(secondSubUnits.length).toBe(subsection.childInfo.children.length - 1);
|
||||
});
|
||||
|
||||
it('check whether unit moves down to next subsection if it is in last position in parent subsection', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [firstSubsection, 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 lastUnitIdx = firstSubsection.childInfo.children.length - 1;
|
||||
const unit = firstSubsection.childInfo.children[lastUnitIdx];
|
||||
const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx];
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 1, 0, lastUnitIdx, 1, 1, 0)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
|
||||
// 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 first unit to last position of prev subsection
|
||||
const moveDownButton = await within(unitElement).findByTestId('unit-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const firstSubUnits = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children;
|
||||
expect(firstSubUnits.length).toBe(firstSubsection.childInfo.children.length - 1);
|
||||
const secondSubUnits = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children;
|
||||
expect(secondSubUnits[0].id).toBe(unit.id);
|
||||
});
|
||||
|
||||
it('check whether unit moves down to next subsection of next section if it is in last position in parent subsection & section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, secondSection, thirdSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const lastSubIndex = secondSection.childInfo.children.length - 1;
|
||||
const secondSectionLastSubsection = secondSection.childInfo.children[lastSubIndex];
|
||||
const thirdSectionFirstSubsection = thirdSection.childInfo.children[0];
|
||||
const subsectionElement = (await within(sectionElement).findAllByTestId('subsection-card'))[lastSubIndex];
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await act(async () => fireEvent.click(expandBtn));
|
||||
const lastUnitIdx = secondSectionLastSubsection.childInfo.children.length - 1;
|
||||
const unit = secondSectionLastSubsection.childInfo.children[lastUnitIdx];
|
||||
const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx];
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(thirdSectionFirstSubsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSections = moveUnitOver(
|
||||
[...courseOutlineIndexMock.courseStructure.childInfo.children],
|
||||
1,
|
||||
lastSubIndex,
|
||||
lastUnitIdx,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
)[0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(secondSection.id))
|
||||
.reply(200, expectedSections[1]);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(thirdSection.id))
|
||||
.reply(200, expectedSections[2]);
|
||||
|
||||
// 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 first unit to last position of prev subsection
|
||||
const moveDownButton = await within(unitElement).findByTestId('unit-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const secondSectionSubStore = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||
const secondSectionLastSubUnits = secondSectionSubStore[secondSectionSubStore.length - 1].childInfo.children;
|
||||
expect(secondSectionLastSubUnits.length).toBe(secondSectionLastSubsection.childInfo.children.length - 1);
|
||||
const thirdSubUnits = store.getState().courseOutline.sectionsList[2].childInfo.children[0].childInfo.children;
|
||||
expect(thirdSubUnits[0].id).toBe(unit.id);
|
||||
});
|
||||
|
||||
it('check whether unit move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// using first section -> first subsection -> first unit
|
||||
const sections = await findAllByTestId('section-card');
|
||||
const [sectionElement] = sections;
|
||||
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 and only unit in the subsection
|
||||
const [firstUnit] = 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
|
||||
// move down option should be enabled in first element as it can move down to next subsection
|
||||
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
|
||||
// move up option should not be enabled in first element as we have no subsections or sections above
|
||||
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');
|
||||
// using last section -> last subsection -> last unit
|
||||
const lastSection = sections[sections.length - 1];
|
||||
// it has only one subsection
|
||||
const [lastSubsectionElement] = await within(lastSection).findAllByTestId('subsection-card');
|
||||
const lastExpandBtn = await within(lastSubsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await act(async () => fireEvent.click(lastExpandBtn));
|
||||
// get last and the only unit in the subsection
|
||||
const [lastUnit] = await within(lastSubsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
// find menu button and click on it to open menu in last section
|
||||
// find menu button and click on it to open menu in first 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
|
||||
// move down option should not be enabled in last element as we have no subsections or sections below
|
||||
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
|
||||
// move down option should be enabled in last element as it can move up to prev section's last subsection
|
||||
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(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = sectionsDraggers[7];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
const section2 = store.getState().courseOutline.sectionsList[1].id;
|
||||
expect(section1).toBe(section2);
|
||||
});
|
||||
|
||||
it('check section list is restored to original order when API call fails', async () => {
|
||||
const { findAllByRole } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = sectionsDraggers[6];
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(500);
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
|
||||
const section1New = store.getState().courseOutline.sectionsList[0].id;
|
||||
expect(section1).toBe(section1New);
|
||||
});
|
||||
|
||||
it('check that new subsection list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
|
||||
@@ -1674,17 +1951,22 @@ describe('<CourseOutline />', () => {
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: subsection1 }]);
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveSubsection([
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children,
|
||||
], 0, 1, 0)[0][0];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
@@ -1700,17 +1982,17 @@ describe('<CourseOutline />', () => {
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: subsection1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
.reply(500);
|
||||
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
@@ -1721,93 +2003,78 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
it('check that new unit list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
|
||||
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||
// get third section
|
||||
const [, , sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const section = store.getState().courseOutline.sectionsList[2];
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = unitDraggers[1];
|
||||
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: unit1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
const expectedSection = moveUnit([...sections], 2, 0, 1, 0)[0][2];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
const unit2 = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[1].id;
|
||||
const unit2 = store.getState().courseOutline.sectionsList[2].childInfo.children[0].childInfo.children[1].id;
|
||||
expect(unit1).toBe(unit2);
|
||||
});
|
||||
|
||||
it('check that new unit list is restored to original order when API call fails', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const subsectionElement = (await findAllByTestId('subsection-card'))[3];
|
||||
const [subsection] = store.getState().courseOutline.sectionsList[1].childInfo.children;
|
||||
// get third section
|
||||
const [, , sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const section = store.getState().courseOutline.sectionsList[2];
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = unitDraggers[1];
|
||||
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: unit1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
.reply(500);
|
||||
const expectedSection = moveUnit([...sections], 2, 0, 1, 0)[0][2];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, expectedSection);
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
|
||||
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await waitFor(async () => {
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
|
||||
const saveStatus = store.getState().courseOutline.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
|
||||
const unit1New = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[0].id;
|
||||
const unit1New = store.getState().courseOutline.sectionsList[2].childInfo.children[0].childInfo.children[0].id;
|
||||
expect(unit1).toBe(unit1New);
|
||||
});
|
||||
|
||||
it('check that drag handle is not visible for non-draggable sections', async () => {
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, {
|
||||
...courseOutlineIndexMock,
|
||||
courseStructure: {
|
||||
...courseOutlineIndexMock.courseStructure,
|
||||
childInfo: {
|
||||
...courseOutlineIndexMock.courseStructure.childInfo,
|
||||
children: [
|
||||
{
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children[0],
|
||||
actions: {
|
||||
draggable: false,
|
||||
childAddable: true,
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
},
|
||||
...courseOutlineIndexMock.courseStructure.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const [sectionElement] = await findAllByTestId('conditional-sortable-element--no-drag-handle');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId, findAllByRole } = render(<RootWrapper />);
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1872,12 +2139,44 @@ describe('<CourseOutline />', () => {
|
||||
.onPost(getXBlockBaseApiUrl(), {
|
||||
parent_locator: subsection.id,
|
||||
staged_content: 'clipboard',
|
||||
}).reply(200, { dummy: 'value' });
|
||||
}).reply(200, {
|
||||
staticFileNotices: {
|
||||
newFiles: ['some.css'],
|
||||
conflictingFiles: ['con.css'],
|
||||
errorFiles: ['error.css'],
|
||||
},
|
||||
});
|
||||
const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage);
|
||||
await act(async () => fireEvent.click(pasteBtn));
|
||||
|
||||
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
|
||||
expect(lastUnitElement).toHaveTextContent(unit.displayName);
|
||||
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({
|
||||
newFiles: ['some.css'],
|
||||
conflictingFiles: ['con.css'],
|
||||
errorFiles: ['error.css'],
|
||||
});
|
||||
|
||||
// 3 alerts should be present
|
||||
const alerts = await findAllByRole('alert');
|
||||
expect(alerts.length).toEqual(3);
|
||||
|
||||
// check alerts for errorFiles
|
||||
let dismissBtn = await within(alerts[0]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
|
||||
// check alerts for conflictingFiles
|
||||
dismissBtn = await within(alerts[1]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
|
||||
// check alerts for newFiles
|
||||
dismissBtn = await within(alerts[2]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ module.exports = {
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
hasChanges: true,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
@@ -231,7 +231,7 @@ module.exports = {
|
||||
'Homework',
|
||||
'Exam',
|
||||
],
|
||||
hasChanges: false,
|
||||
hasChanges: true,
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
@@ -392,7 +392,7 @@ module.exports = {
|
||||
proctoring_exam_configuration_link: null,
|
||||
supports_onboarding: true,
|
||||
show_review_rules: true,
|
||||
child_info: {
|
||||
childInfo: {
|
||||
category: 'vertical',
|
||||
display_name: 'Unit',
|
||||
children: [],
|
||||
|
||||
@@ -233,12 +233,13 @@ CardHeader.defaultProps = {
|
||||
parentInfo: {},
|
||||
onClickManageTags: null,
|
||||
tagsCount: undefined,
|
||||
cardId: '',
|
||||
};
|
||||
|
||||
CardHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
cardId: PropTypes.string.isRequired,
|
||||
cardId: PropTypes.string,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
onClickPublish: PropTypes.func.isRequired,
|
||||
onClickConfigure: PropTypes.func.isRequired,
|
||||
|
||||
@@ -35,7 +35,7 @@ StatusBadge.defaultProps = {
|
||||
|
||||
StatusBadge.propTypes = {
|
||||
text: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
icon: PropTypes.func,
|
||||
iconClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export const ITEM_BADGE_STATUS = /** @type {const} */ ({
|
||||
unpublishedChanges: 'unpublished_changes',
|
||||
staffOnly: 'staff_only',
|
||||
draft: 'draft',
|
||||
unscheduled: 'unscheduled',
|
||||
needs_attention: 'needs_attention',
|
||||
});
|
||||
|
||||
export const HIGHLIGHTS_FIELD_MAX_LENGTH = 250;
|
||||
|
||||
@@ -461,7 +461,7 @@ export async function pasteBlock(parentLocator) {
|
||||
staged_content: 'clipboard',
|
||||
});
|
||||
|
||||
return data;
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,3 +10,4 @@ export const getCourseActions = (state) => state.courseOutline.actions;
|
||||
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
|
||||
export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard;
|
||||
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
|
||||
export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices;
|
||||
|
||||
@@ -45,6 +45,7 @@ const slice = createSlice({
|
||||
sourceEditUrl: null,
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
pasteFileNotices: {},
|
||||
},
|
||||
reducers: {
|
||||
fetchOutlineIndexSuccess: (state, { payload }) => {
|
||||
@@ -100,7 +101,7 @@ const slice = createSlice({
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateSectionList: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => (section.id === payload.id ? payload : section));
|
||||
state.sectionsList = state.sectionsList.map((section) => (section.id in payload ? payload[section.id] : section));
|
||||
},
|
||||
setCurrentItem: (state, { payload }) => {
|
||||
state.currentItem = payload;
|
||||
@@ -111,22 +112,6 @@ const slice = createSlice({
|
||||
|
||||
state.sectionsList = [...sectionsList];
|
||||
},
|
||||
reorderSubsectionList: (state, { payload }) => {
|
||||
const { sectionId, subsectionListIds } = payload;
|
||||
const sections = [...state.sectionsList];
|
||||
const i = sections.findIndex(section => section.id === sectionId);
|
||||
sections[i].childInfo.children.sort((a, b) => subsectionListIds.indexOf(a.id) - subsectionListIds.indexOf(b.id));
|
||||
state.sectionsList = [...sections];
|
||||
},
|
||||
reorderUnitList: (state, { payload }) => {
|
||||
const { sectionId, subsectionId, unitListIds } = payload;
|
||||
const sections = [...state.sectionsList];
|
||||
const i = sections.findIndex(section => section.id === sectionId);
|
||||
const j = sections[i].childInfo.children.findIndex(subsection => subsection.id === subsectionId);
|
||||
const subsection = sections[i].childInfo.children[j];
|
||||
subsection.childInfo.children.sort((a, b) => unitListIds.indexOf(a.id) - unitListIds.indexOf(b.id));
|
||||
state.sectionsList = [...sections];
|
||||
},
|
||||
setCurrentSection: (state, { payload }) => {
|
||||
state.currentSection = payload;
|
||||
},
|
||||
@@ -191,6 +176,14 @@ const slice = createSlice({
|
||||
return [...result, currentValue];
|
||||
}, []);
|
||||
},
|
||||
setPasteFileNotices: (state, { payload }) => {
|
||||
state.pasteFileNotices = payload;
|
||||
},
|
||||
removePasteFileNotices: (state, { payload }) => {
|
||||
const pasteFileNotices = { ...state.pasteFileNotices };
|
||||
payload.forEach((key) => delete pasteFileNotices[key]);
|
||||
state.pasteFileNotices = pasteFileNotices;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -218,6 +211,8 @@ export const {
|
||||
reorderSubsectionList,
|
||||
reorderUnitList,
|
||||
updateClipboardContent,
|
||||
setPasteFileNotices,
|
||||
removePasteFileNotices,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -50,9 +50,8 @@ import {
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
reorderSubsectionList,
|
||||
reorderUnitList,
|
||||
updateClipboardContent,
|
||||
setPasteFileNotices,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
@@ -172,14 +171,18 @@ export function fetchCourseReindexQuery(courseId, reindexLink) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSectionQuery(sectionId, shouldScroll = false) {
|
||||
export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const data = await getCourseItem(sectionId);
|
||||
data.shouldScroll = shouldScroll;
|
||||
dispatch(updateSectionList(data));
|
||||
const sections = {};
|
||||
const results = await Promise.all(sectionIds.map((sectionId) => getCourseItem(sectionId)));
|
||||
results.forEach((data) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
data.shouldScroll = shouldScroll;
|
||||
sections[data.id] = data;
|
||||
});
|
||||
dispatch(updateSectionList(sections));
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
@@ -195,7 +198,7 @@ export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
|
||||
try {
|
||||
await updateCourseSectionHighlights(sectionId, highlights).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
@@ -215,7 +218,7 @@ export function publishCourseItemQuery(itemId, sectionId) {
|
||||
try {
|
||||
await publishCourseSection(itemId).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
@@ -235,7 +238,7 @@ export function configureCourseItemQuery(sectionId, configureFn) {
|
||||
try {
|
||||
await configureFn().then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
@@ -319,7 +322,7 @@ export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
try {
|
||||
await editItemDisplayName(itemId, displayName).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
@@ -428,7 +431,7 @@ export function duplicateSubsectionQuery(subsectionId, sectionId) {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
|
||||
async () => dispatch(fetchCourseSectionQuery([sectionId], true)),
|
||||
));
|
||||
};
|
||||
}
|
||||
@@ -438,7 +441,7 @@ export function duplicateUnitQuery(unitId, subsectionId, sectionId) {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
unitId,
|
||||
subsectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
|
||||
async () => dispatch(fetchCourseSectionQuery([sectionId], true)),
|
||||
));
|
||||
};
|
||||
}
|
||||
@@ -518,66 +521,89 @@ export function addNewUnitQuery(parentLocator, callback) {
|
||||
};
|
||||
}
|
||||
|
||||
function setBlockOrderListQuery(
|
||||
parentId,
|
||||
blockIds,
|
||||
apiFn,
|
||||
restoreCallback,
|
||||
successCallback,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await apiFn(parentId, blockIds).then(async (result) => {
|
||||
if (result) {
|
||||
successCallback();
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setSectionOrderList(courseId, sectionListIds).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(reorderSectionList(sectionListIds));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
dispatch(setBlockOrderListQuery(
|
||||
courseId,
|
||||
sectionListIds,
|
||||
setSectionOrderList,
|
||||
restoreCallback,
|
||||
() => dispatch(reorderSectionList(sectionListIds)),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function setSubsectionOrderListQuery(sectionId, subsectionListIds, restoreCallback) {
|
||||
export function setSubsectionOrderListQuery(
|
||||
sectionId,
|
||||
prevSectionId,
|
||||
subsectionListIds,
|
||||
restoreCallback,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setCourseItemOrderList(sectionId, subsectionListIds).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(reorderSubsectionList({ sectionId, subsectionListIds }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(setBlockOrderListQuery(
|
||||
sectionId,
|
||||
subsectionListIds,
|
||||
setCourseItemOrderList,
|
||||
restoreCallback,
|
||||
() => {
|
||||
const sectionIds = [sectionId];
|
||||
if (prevSectionId && prevSectionId !== sectionId) {
|
||||
sectionIds.push(prevSectionId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
dispatch(fetchCourseSectionQuery(sectionIds));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback) {
|
||||
export function setUnitOrderListQuery(
|
||||
sectionId,
|
||||
subsectionId,
|
||||
prevSectionId,
|
||||
unitListIds,
|
||||
restoreCallback,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setCourseItemOrderList(subsectionId, unitListIds).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(reorderUnitList({ sectionId, subsectionId, unitListIds }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(setBlockOrderListQuery(
|
||||
subsectionId,
|
||||
unitListIds,
|
||||
setCourseItemOrderList,
|
||||
restoreCallback,
|
||||
() => {
|
||||
const sectionIds = [sectionId];
|
||||
if (prevSectionId && prevSectionId !== sectionId) {
|
||||
sectionIds.push(prevSectionId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
dispatch(fetchCourseSectionQuery(sectionIds));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -613,9 +639,10 @@ export function pasteClipboardContent(parentLocator, sectionId) {
|
||||
try {
|
||||
await pasteBlock(parentLocator).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(fetchCourseSectionQuery(sectionId, true));
|
||||
dispatch(fetchCourseSectionQuery([sectionId], true));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(setPasteFileNotices(result?.staticFileNotices));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Col, Row } from '@openedx/paragon';
|
||||
import { SortableItem } from '@edx/frontend-lib-content-components';
|
||||
|
||||
const ConditionalSortableElement = ({
|
||||
id,
|
||||
draggable,
|
||||
children,
|
||||
componentStyle,
|
||||
}) => {
|
||||
const style = {
|
||||
background: 'white',
|
||||
padding: '1rem 1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderRadius: '0.35rem',
|
||||
boxShadow: '0 0 .125rem rgba(0, 0, 0, .15), 0 0 .25rem rgba(0, 0, 0, .15)',
|
||||
...componentStyle,
|
||||
};
|
||||
|
||||
if (draggable) {
|
||||
return (
|
||||
<SortableItem
|
||||
id={id}
|
||||
componentStyle={style}
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
{children}
|
||||
</Col>
|
||||
</SortableItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Row
|
||||
data-testid="conditional-sortable-element--no-drag-handle"
|
||||
style={style}
|
||||
className="mx-0"
|
||||
>
|
||||
<Col className="px-0">
|
||||
{children}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
ConditionalSortableElement.defaultProps = {
|
||||
componentStyle: null,
|
||||
};
|
||||
|
||||
ConditionalSortableElement.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
componentStyle: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
export default ConditionalSortableElement;
|
||||
31
src/course-outline/drag-helper/DragContextProvider.jsx
Normal file
31
src/course-outline/drag-helper/DragContextProvider.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const DragContext = React.createContext({});
|
||||
|
||||
const DragContextProvider = ({ activeId, overId, children }) => {
|
||||
const contextValue = React.useMemo(() => ({
|
||||
activeId,
|
||||
overId,
|
||||
}), [activeId, overId]);
|
||||
return (
|
||||
<DragContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</DragContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
DragContextProvider.defaultProps = {
|
||||
activeId: '',
|
||||
overId: '',
|
||||
};
|
||||
|
||||
DragContextProvider.propTypes = {
|
||||
activeId: PropTypes.string,
|
||||
overId: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default DragContextProvider;
|
||||
362
src/course-outline/drag-helper/DraggableList.jsx
Normal file
362
src/course-outline/drag-helper/DraggableList.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
closestCorners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
|
||||
import DragContextProvider from './DragContextProvider';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
moveSubsectionOver,
|
||||
moveUnitOver,
|
||||
moveSubsection,
|
||||
moveUnit,
|
||||
dragHelpers,
|
||||
} from './utils';
|
||||
|
||||
const DraggableList = ({
|
||||
items,
|
||||
setSections,
|
||||
restoreSectionList,
|
||||
handleSectionDragAndDrop,
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
children,
|
||||
}) => {
|
||||
const prevContainerInfo = React.useRef();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const [activeId, setActiveId] = React.useState();
|
||||
const [currentOverId, setCurrentOverId] = React.useState();
|
||||
|
||||
const findItemInfo = (id) => {
|
||||
// search id in sections
|
||||
const sectionIndex = items.findIndex((section) => section.id === id);
|
||||
if (sectionIndex !== -1) {
|
||||
return {
|
||||
index: sectionIndex,
|
||||
item: items[sectionIndex],
|
||||
category: COURSE_BLOCK_NAMES.chapter.id,
|
||||
parent: 'root',
|
||||
};
|
||||
}
|
||||
|
||||
// search id in subsections
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const section = items[index];
|
||||
const subsectionIndex = section.childInfo.children.findIndex((subsection) => subsection.id === id);
|
||||
if (subsectionIndex !== -1) {
|
||||
return {
|
||||
index: subsectionIndex,
|
||||
item: section.childInfo.children[subsectionIndex],
|
||||
category: COURSE_BLOCK_NAMES.sequential.id,
|
||||
parentIndex: index,
|
||||
parent: section,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// search id in units
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const section = items[index];
|
||||
for (let subIndex = 0; subIndex < section.childInfo.children.length; subIndex++) {
|
||||
const subsection = section.childInfo.children[subIndex];
|
||||
const unitIndex = subsection.childInfo.children.findIndex((unit) => unit.id === id);
|
||||
if (unitIndex !== -1) {
|
||||
return {
|
||||
index: unitIndex,
|
||||
item: subsection.childInfo.children[unitIndex],
|
||||
category: COURSE_BLOCK_NAMES.vertical.id,
|
||||
parentIndex: subIndex,
|
||||
parent: subsection,
|
||||
grandParentIndex: index,
|
||||
grandParent: section,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// For reasons unknown, onDragEnd is not being triggered by dnd-kit while
|
||||
// testing drag over functions. The main functions responsible to move units
|
||||
// & subsections across parents are already tested as part of move blocks by
|
||||
// index in CourseOutline.test.jsx, just these functions which determine the
|
||||
// new index and parent are ignored.
|
||||
// See https://github.com/openedx/frontend-app-course-authoring/pull/859#discussion_r1519199622
|
||||
// for more details.
|
||||
/* istanbul ignore next */
|
||||
const subsectionDragOver = (active, over, activeInfo, overInfo) => {
|
||||
if (
|
||||
activeInfo.parent.id === overInfo.parent.id
|
||||
|| activeInfo.parent.id === overInfo.item.id
|
||||
|| (activeInfo.category === overInfo.category && !overInfo.parent.actions.childAddable)
|
||||
|| (activeInfo.parent.category === overInfo.category && !overInfo.item.actions.childAddable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Find the new index for the item
|
||||
let overSectionIndex;
|
||||
let newIndex;
|
||||
if (overInfo.category === COURSE_BLOCK_NAMES.chapter.id) {
|
||||
// We're at the root droppable of a container
|
||||
newIndex = overInfo.item.childInfo.children.length + 1;
|
||||
overSectionIndex = overInfo.index;
|
||||
setCurrentOverId(overInfo.item.id);
|
||||
} else {
|
||||
const modifier = dragHelpers.isBelowOverItem(active, over) ? 1 : 0;
|
||||
newIndex = overInfo.index >= 0 ? overInfo.index + modifier : overInfo.item.childInfo.children.length + 1;
|
||||
overSectionIndex = overInfo.parentIndex;
|
||||
setCurrentOverId(overInfo.parent.id);
|
||||
}
|
||||
|
||||
setSections((prev) => {
|
||||
const [prevCopy] = moveSubsectionOver(
|
||||
[...prev],
|
||||
activeInfo.parentIndex,
|
||||
activeInfo.index,
|
||||
overSectionIndex,
|
||||
newIndex,
|
||||
);
|
||||
return prevCopy;
|
||||
});
|
||||
if (prevContainerInfo.current === null || prevContainerInfo.current === undefined) {
|
||||
prevContainerInfo.current = activeInfo.parent.id;
|
||||
}
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const unitDragOver = (active, over, activeInfo, overInfo) => {
|
||||
if (
|
||||
activeInfo.parent.id === overInfo.parent.id
|
||||
|| activeInfo.parent.id === overInfo.item.id
|
||||
|| (activeInfo.category === overInfo.category && !overInfo.parent.actions.childAddable)
|
||||
|| (activeInfo.parent.category === overInfo.category && !overInfo.item.actions.childAddable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let overSubsectionIndex;
|
||||
let overSectionIndex;
|
||||
// Find the indexes for the items
|
||||
let newIndex;
|
||||
if (overInfo.category === COURSE_BLOCK_NAMES.sequential.id) {
|
||||
// We're at the root droppable of a container
|
||||
newIndex = overInfo.item.childInfo.children.length + 1;
|
||||
overSubsectionIndex = overInfo.index;
|
||||
overSectionIndex = overInfo.parentIndex;
|
||||
setCurrentOverId(overInfo.item.id);
|
||||
} else {
|
||||
const modifier = dragHelpers.isBelowOverItem(active, over) ? 1 : 0;
|
||||
newIndex = overInfo.index >= 0 ? overInfo.index + modifier : overInfo.item.childInfo.children.length + 1;
|
||||
overSubsectionIndex = overInfo.parentIndex;
|
||||
overSectionIndex = overInfo.grandParentIndex;
|
||||
setCurrentOverId(overInfo.parent.id);
|
||||
}
|
||||
|
||||
setSections((prev) => {
|
||||
const [prevCopy] = moveUnitOver(
|
||||
[...prev],
|
||||
activeInfo.grandParentIndex,
|
||||
activeInfo.parentIndex,
|
||||
activeInfo.index,
|
||||
overSectionIndex,
|
||||
overSubsectionIndex,
|
||||
newIndex,
|
||||
);
|
||||
return prevCopy;
|
||||
});
|
||||
if (prevContainerInfo.current === null || prevContainerInfo.current === undefined) {
|
||||
prevContainerInfo.current = activeInfo.grandParent.id;
|
||||
}
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const handleDragOver = (event) => {
|
||||
const { active, over } = event;
|
||||
if (!active || !over) {
|
||||
return;
|
||||
}
|
||||
const { id } = active;
|
||||
const { id: overId } = over;
|
||||
|
||||
// Find the containers
|
||||
const activeInfo = findItemInfo(id);
|
||||
const overInfo = findItemInfo(overId);
|
||||
if (!activeInfo || !overInfo) {
|
||||
return;
|
||||
}
|
||||
switch (activeInfo.category) {
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
subsectionDragOver(active, over, activeInfo, overInfo);
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
unitDragOver(active, over, activeInfo, overInfo);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (!active || !over) {
|
||||
return;
|
||||
}
|
||||
setActiveId(null);
|
||||
setCurrentOverId(null);
|
||||
const { id } = active;
|
||||
const { id: overId } = over;
|
||||
|
||||
const activeInfo = findItemInfo(id);
|
||||
const overInfo = findItemInfo(overId);
|
||||
if (!activeInfo || !overInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
activeInfo.category !== overInfo.category
|
||||
|| (activeInfo.parent !== 'root' && activeInfo.parentIndex !== overInfo.parentIndex)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeInfo.index !== overInfo.index || prevContainerInfo.current) {
|
||||
switch (activeInfo.category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
setSections((prev) => {
|
||||
const result = arrayMove(prev, activeInfo.index, overInfo.index);
|
||||
handleSectionDragAndDrop(result.map(section => section.id), restoreSectionList);
|
||||
return result;
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
setSections((prev) => {
|
||||
const [prevCopy, result] = moveSubsection(
|
||||
[...prev],
|
||||
activeInfo.parentIndex,
|
||||
activeInfo.index,
|
||||
overInfo.index,
|
||||
);
|
||||
handleSubsectionDragAndDrop(
|
||||
activeInfo.parent.id,
|
||||
prevContainerInfo.current,
|
||||
result.map(subsection => subsection.id),
|
||||
restoreSectionList,
|
||||
);
|
||||
return prevCopy;
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
setSections((prev) => {
|
||||
const [prevCopy, result] = moveUnit(
|
||||
[...prev],
|
||||
activeInfo.grandParentIndex,
|
||||
activeInfo.parentIndex,
|
||||
activeInfo.index,
|
||||
overInfo.index,
|
||||
);
|
||||
handleUnitDragAndDrop(
|
||||
activeInfo.grandParent.id,
|
||||
prevContainerInfo.current,
|
||||
activeInfo.parent.id,
|
||||
result.map(unit => unit.id),
|
||||
restoreSectionList,
|
||||
);
|
||||
return prevCopy;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
prevContainerInfo.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event) => {
|
||||
const { active } = event;
|
||||
const { id } = active;
|
||||
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
const customClosestCorners = ({
|
||||
active, droppableContainers, droppableRects, ...args
|
||||
}) => {
|
||||
const activeCategory = active.data?.current?.category;
|
||||
const filteredContainers = droppableContainers.filter(
|
||||
(container) => {
|
||||
switch (activeCategory) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
return container.data?.current?.category === activeCategory;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
return [activeCategory, COURSE_BLOCK_NAMES.chapter.id].includes(container.data?.current?.category);
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
return [activeCategory, COURSE_BLOCK_NAMES.sequential.id].includes(container.data?.current?.category);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
);
|
||||
return closestCorners({
|
||||
active, droppableContainers: filteredContainers, droppableRects, ...args,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
sensors={sensors}
|
||||
collisionDetection={customClosestCorners}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DragContextProvider activeId={activeId} overId={currentOverId}>
|
||||
{children}
|
||||
</DragContextProvider>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
DraggableList.propTypes = {
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
childInfo: PropTypes.shape({
|
||||
children: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
childInfo: PropTypes.shape({
|
||||
children: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
}).isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
}).isRequired,
|
||||
})).isRequired,
|
||||
setSections: PropTypes.func.isRequired,
|
||||
restoreSectionList: PropTypes.func.isRequired,
|
||||
handleSectionDragAndDrop: PropTypes.func.isRequired,
|
||||
handleSubsectionDragAndDrop: PropTypes.func.isRequired,
|
||||
handleUnitDragAndDrop: PropTypes.func.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default DraggableList;
|
||||
101
src/course-outline/drag-helper/SortableItem.jsx
Normal file
101
src/course-outline/drag-helper/SortableItem.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { intlShape, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
Col, Icon, Row,
|
||||
} from '@openedx/paragon';
|
||||
import { DragIndicator } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const SortableItem = ({
|
||||
id,
|
||||
category,
|
||||
isDraggable,
|
||||
isDroppable,
|
||||
componentStyle,
|
||||
children,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
setActivatorNodeRef,
|
||||
} = useSortable({
|
||||
id,
|
||||
data: {
|
||||
category,
|
||||
},
|
||||
disabled: {
|
||||
draggable: !isDraggable,
|
||||
droppable: !isDroppable,
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
const style = {
|
||||
position: 'relative',
|
||||
zIndex: isDragging ? 200 : undefined,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
background: 'white',
|
||||
padding: '1rem 1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderRadius: '0.35rem',
|
||||
boxShadow: '0 0 .125rem rgba(0, 0, 0, .15), 0 0 .25rem rgba(0, 0, 0, .15)',
|
||||
...componentStyle,
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="mx-0"
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
{children}
|
||||
</Col>
|
||||
{isDraggable && (
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
key="drag-to-reorder-icon"
|
||||
aria-label={intl.formatMessage(messages.tooltipContent)}
|
||||
className="btn-icon btn-icon-secondary btn-icon-md"
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<span className="btn-icon__icon-container">
|
||||
<Icon src={DragIndicator} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
11
src/course-outline/drag-helper/messages.js
Normal file
11
src/course-outline/drag-helper/messages.js
Normal file
@@ -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;
|
||||
331
src/course-outline/drag-helper/utils.js
Normal file
331
src/course-outline/drag-helper/utils.js
Normal file
@@ -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 {};
|
||||
};
|
||||
@@ -236,26 +236,55 @@ const useCourseOutline = ({ courseId }) => {
|
||||
dispatch(duplicateUnitQuery(currentItem.id, currentSubsection.id, currentSection.id));
|
||||
};
|
||||
|
||||
const handleSectionDragAndDrop = (sectionListIds, restoreCallback) => {
|
||||
dispatch(setSectionOrderListQuery(courseId, sectionListIds, restoreCallback));
|
||||
};
|
||||
|
||||
const handleSubsectionDragAndDrop = (sectionId, subsectionListIds, restoreCallback) => {
|
||||
dispatch(setSubsectionOrderListQuery(sectionId, subsectionListIds, restoreCallback));
|
||||
};
|
||||
|
||||
const handleVideoSharingOptionChange = (value) => {
|
||||
dispatch(setVideoSharingOptionQuery(courseId, value));
|
||||
};
|
||||
|
||||
const handleUnitDragAndDrop = (sectionId, subsectionId, unitListIds, restoreCallback) => {
|
||||
dispatch(setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback));
|
||||
};
|
||||
|
||||
const handleDismissNotification = () => {
|
||||
dispatch(dismissNotificationQuery(notificationDismissUrl));
|
||||
};
|
||||
|
||||
const handleSectionDragAndDrop = (
|
||||
sectionListIds,
|
||||
restoreSectionList,
|
||||
) => {
|
||||
dispatch(setSectionOrderListQuery(
|
||||
courseId,
|
||||
sectionListIds,
|
||||
restoreSectionList,
|
||||
));
|
||||
};
|
||||
|
||||
const handleSubsectionDragAndDrop = (
|
||||
sectionId,
|
||||
prevSectionId,
|
||||
subsectionListIds,
|
||||
restoreSectionList,
|
||||
) => {
|
||||
dispatch(setSubsectionOrderListQuery(
|
||||
sectionId,
|
||||
prevSectionId,
|
||||
subsectionListIds,
|
||||
restoreSectionList,
|
||||
));
|
||||
};
|
||||
|
||||
const handleUnitDragAndDrop = (
|
||||
sectionId,
|
||||
prevSectionId,
|
||||
subsectionId,
|
||||
unitListIds,
|
||||
restoreSectionList,
|
||||
) => {
|
||||
dispatch(setUnitOrderListQuery(
|
||||
sectionId,
|
||||
subsectionId,
|
||||
prevSectionId,
|
||||
unitListIds,
|
||||
restoreSectionList,
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseOutlineIndexQuery(courseId));
|
||||
dispatch(fetchCourseBestPracticesQuery({ courseId }));
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: newFiles.length })}
|
||||
description={intl.formatMessage(
|
||||
messages.newFileAlertDesc,
|
||||
{ newFilesLen: newFiles.length, newFilesStr: newFiles.join(', ') },
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
icon={CampaignIcon}
|
||||
variant="info"
|
||||
onClose={onDismiss}
|
||||
actions={[
|
||||
<Button
|
||||
as={Link}
|
||||
to={getAssetsUrl()}
|
||||
>
|
||||
{intl.formatMessage(messages.newFileAlertAction)}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const errorFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['errorFiles']));
|
||||
};
|
||||
|
||||
if (errorFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.errorFileAlertTitle)}
|
||||
description={intl.formatMessage(
|
||||
messages.errorFileAlertDesc,
|
||||
{ errorFilesLen: errorFiles.length, errorFilesStr: errorFiles.join(', ') },
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
icon={WarningIcon}
|
||||
variant="danger"
|
||||
onClose={onDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const conflictingFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['conflictingFiles']));
|
||||
};
|
||||
|
||||
if (conflictingFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(
|
||||
messages.conflictingFileAlertTitle,
|
||||
{ conflictingFilesLen: conflictingFiles.length },
|
||||
)}
|
||||
description={intl.formatMessage(
|
||||
messages.conflictingFileAlertDesc,
|
||||
{ conflictingFilesLen: conflictingFiles.length, conflictingFilesStr: conflictingFiles.join(', ') },
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
icon={WarningIcon}
|
||||
variant="warning"
|
||||
onClose={onDismiss}
|
||||
actions={[
|
||||
<Button
|
||||
as={Link}
|
||||
to={getAssetsUrl()}
|
||||
>
|
||||
{intl.formatMessage(messages.newFileAlertAction)}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{configurationErrors()}
|
||||
@@ -234,6 +339,9 @@ const PageAlerts = ({
|
||||
<ErrorAlert hideHeading isError={savingStatus === RequestStatus.FAILED}>
|
||||
{intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })}
|
||||
</ErrorAlert>
|
||||
{errorFilesPasteAlert()}
|
||||
{conflictingFilesPasteAlert()}
|
||||
{newFilesPasteAlert()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -252,6 +360,7 @@ PageAlerts.defaultProps = {
|
||||
};
|
||||
|
||||
PageAlerts.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
notificationDismissUrl: PropTypes.string,
|
||||
handleDismissNotification: PropTypes.func,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
|
||||
@@ -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('<PageAlerts />', () => {
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
useSelector.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('renders null when no alerts are present', () => {
|
||||
@@ -152,4 +160,33 @@ describe('<PageAlerts />', () => {
|
||||
`${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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ConditionalSortableElement
|
||||
<SortableItem
|
||||
id={id}
|
||||
draggable={isDraggable}
|
||||
category={category}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
padding: '1.75rem',
|
||||
...borderStyle,
|
||||
@@ -211,7 +224,7 @@ const SectionCard = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ConditionalSortableElement>
|
||||
</SortableItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<IntlProvider locale="en">
|
||||
<SectionCard
|
||||
section={section}
|
||||
index="1"
|
||||
index={1}
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
onNewSubsectionSubmit={jest.fn()}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
|
||||
@@ -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 (
|
||||
<ConditionalSortableElement
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
key={id}
|
||||
draggable={isDraggable}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#f8f7f6',
|
||||
...borderStyle,
|
||||
@@ -205,7 +223,7 @@ const SubsectionCard = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConditionalSortableElement>
|
||||
</SortableItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index="1"
|
||||
canMoveItem={jest.fn()}
|
||||
index={1}
|
||||
isSelfPaced={false}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onNewUnitSubmit={jest.fn()}
|
||||
isCustomRelativeDatesActive={false}
|
||||
onEditClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
namePrefix="subsection"
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
@@ -204,6 +211,7 @@ describe('<SubsectionCard />', () => {
|
||||
...subsection,
|
||||
published: false,
|
||||
visibilityState: 'needs_attention',
|
||||
hasChanges: true,
|
||||
},
|
||||
});
|
||||
expect(await findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ConditionalSortableElement
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
key={id}
|
||||
draggable={isDraggable}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
@@ -176,7 +182,7 @@ const UnitCard = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ConditionalSortableElement>
|
||||
</SortableItem>
|
||||
<Sheet
|
||||
position="right"
|
||||
show={showManageTags}
|
||||
@@ -200,6 +206,7 @@ UnitCard.propTypes = {
|
||||
unit: 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,
|
||||
@@ -240,7 +247,7 @@ UnitCard.propTypes = {
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
getTitleLink: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
canMoveItem: PropTypes.func.isRequired,
|
||||
getPossibleMoves: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -36,6 +36,7 @@ const subsection = {
|
||||
const unit = {
|
||||
id: '123',
|
||||
displayName: 'unit Name',
|
||||
category: 'vertical',
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
hasChanges: false,
|
||||
@@ -55,15 +56,19 @@ const renderComponent = (props) => 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}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
@@ -133,4 +138,15 @@ describe('<UnitCard />', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
18
src/generic/DraftIcon.jsx
Normal file
18
src/generic/DraftIcon.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
const DraftIcon = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 -960 960 960"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default DraftIcon;
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user