feat: allow dragging blocks across parents in outline (#859)

This commit is contained in:
Navin Karkera
2024-03-19 19:55:02 +05:30
committed by GitHub
parent 972a7f324c
commit da68fb8e9d
41 changed files with 1964 additions and 543 deletions

1
.env
View File

@@ -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=''

View File

@@ -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=''

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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";

View File

@@ -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({});
});
});

View File

@@ -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: [],

View File

@@ -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,

View File

@@ -35,7 +35,7 @@ StatusBadge.defaultProps = {
StatusBadge.propTypes = {
text: PropTypes.string,
icon: PropTypes.string,
icon: PropTypes.func,
iconClassName: PropTypes.string,
};

View File

@@ -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;

View File

@@ -461,7 +461,7 @@ export async function pasteBlock(parentLocator) {
staged_content: 'clipboard',
});
return data;
return camelCaseObject(data);
}
/**

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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;

View 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;

View 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;

View 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);

View 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;

View 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 {};
};

View File

@@ -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,
};
};

View File

@@ -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({

View File

@@ -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`,
);
});
});

View File

@@ -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.',
},
});

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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 {};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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
View 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;

View File

@@ -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',