feat: move up & down menu action for sections, subsections & units
test: add tests for move options refactor: disable move option instead of hiding fix: incorrect variable name in tests feat: move up & down menu action for units test: add tests for unit move options
This commit is contained in:
committed by
Kristin Aoki
parent
d2f63b8b16
commit
53118a4e0b
46
package-lock.json
generated
46
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations-edx": "^1.4.2",
|
||||
"@edx/frontend-component-footer": "^12.3.0",
|
||||
@@ -2337,9 +2338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz",
|
||||
"integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
|
||||
"integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
@@ -2348,12 +2349,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz",
|
||||
"integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz",
|
||||
"integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@dnd-kit/accessibility": "^3.1.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -2362,22 +2363,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz",
|
||||
"integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.0.7",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz",
|
||||
"integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==",
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
@@ -2811,6 +2812,19 @@
|
||||
"react-dom": "^16.14.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-lib-content-components/node_modules/@dnd-kit/sortable": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz",
|
||||
"integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.0.7",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-lib-content-components/node_modules/react-responsive": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations-edx": "^1.4.2",
|
||||
"@edx/frontend-component-footer": "^12.3.0",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DraggableList,
|
||||
ErrorAlert,
|
||||
} from '@edx/frontend-lib-content-components';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
@@ -148,6 +149,86 @@ const CourseOutline = ({ courseId }) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if item can be moved by given step.
|
||||
* Inner function returns false if the new index after moving by given step
|
||||
* is out of bounds of item length.
|
||||
* If it is within bounds, returns draggable flag of the item in the new index.
|
||||
* This helps us avoid moving the item to a position of unmovable item.
|
||||
* @param {Array} items
|
||||
* @returns {(id, step) => bool}
|
||||
*/
|
||||
const canMoveItem = (items) => (id, step) => {
|
||||
const newId = id + step;
|
||||
const indexCheck = newId >= 0 && newId < items.length;
|
||||
if (!indexCheck) {
|
||||
return false;
|
||||
}
|
||||
const newItem = items[newId];
|
||||
return newItem.actions.draggable;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move section to new index
|
||||
* @param {any} currentIndex
|
||||
* @param {any} newIndex
|
||||
*/
|
||||
const updateSectionOrderByIndex = (currentIndex, newIndex) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
setSections((prevSections) => {
|
||||
const newSections = arrayMove(prevSections, currentIndex, newIndex);
|
||||
finalizeSectionOrder()(newSections);
|
||||
return newSections;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a function for given section which can move a subsection inside it
|
||||
* to a new position
|
||||
* @param {any} sectionIndex
|
||||
* @param {any} section
|
||||
* @param {any} subsections
|
||||
* @returns {(currentIndex, newIndex) => void}
|
||||
*/
|
||||
const updateSubsectionOrderByIndex = (sectionIndex, section, subsections) => (currentIndex, newIndex) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
setSubsection(sectionIndex)(() => {
|
||||
const newSubsections = arrayMove(subsections, currentIndex, newIndex);
|
||||
finalizeSubsectionOrder(section)()(newSubsections);
|
||||
return newSubsections;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a function for given section & subsection which can move a unit
|
||||
* inside it to a new position
|
||||
* @param {any} sectionIndex
|
||||
* @param {any} section
|
||||
* @param {any} subsection
|
||||
* @param {any} units
|
||||
* @returns {(currentIndex, newIndex) => void}
|
||||
*/
|
||||
const updateUnitOrderByIndex = (
|
||||
sectionIndex,
|
||||
subsectionIndex,
|
||||
section,
|
||||
subsection,
|
||||
units,
|
||||
) => (currentIndex, newIndex) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
setUnit(sectionIndex, subsectionIndex)(() => {
|
||||
const newUnits = arrayMove(units, currentIndex, newIndex);
|
||||
finalizeUnitOrder(section, subsection)()(newUnits);
|
||||
return newUnits;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSections(sectionsList);
|
||||
}, [sectionsList]);
|
||||
@@ -228,6 +309,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
id={section.id}
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={sectionIndex}
|
||||
canMoveItem={canMoveItem(sections)}
|
||||
savingStatus={savingStatus}
|
||||
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
@@ -237,6 +320,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
>
|
||||
<DraggableList
|
||||
itemList={section.childInfo.children}
|
||||
@@ -248,30 +332,46 @@ const CourseOutline = ({ courseId }) => {
|
||||
key={subsection.id}
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={subsectionIndex}
|
||||
canMoveItem={canMoveItem(section.childInfo.children)}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
onOrderChange={updateSubsectionOrderByIndex(
|
||||
sectionIndex,
|
||||
section,
|
||||
section.childInfo.children,
|
||||
)}
|
||||
>
|
||||
<DraggableList
|
||||
itemList={subsection.childInfo.children}
|
||||
setState={setUnit(sectionIndex, subsectionIndex)}
|
||||
updateOrder={finalizeUnitOrder(section, subsection)}
|
||||
>
|
||||
{subsection.childInfo.children.map((unit) => (
|
||||
{subsection.childInfo.children.map((unit, unitIndex) => (
|
||||
<UnitCard
|
||||
key={unit.id}
|
||||
unit={unit}
|
||||
subsection={subsection}
|
||||
section={section}
|
||||
index={unitIndex}
|
||||
canMoveItem={canMoveItem(subsection.childInfo.children)}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
onOrderChange={updateUnitOrderByIndex(
|
||||
sectionIndex,
|
||||
subsectionIndex,
|
||||
section,
|
||||
subsection,
|
||||
subsection.childInfo.children,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</DraggableList>
|
||||
|
||||
@@ -686,6 +686,241 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('check whether section move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section element
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseBlockApiUrl(courseBlockId))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
|
||||
// move second section to first position to test move up option
|
||||
const moveUpButton = await within(sectionElement).findByTestId('section-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstSectionId = store.getState().courseOutline.sectionsList[0].id;
|
||||
expect(secondSection.id).toBe(firstSectionId);
|
||||
|
||||
// move first section back to second position to test move down option
|
||||
const moveDownButton = await within(sectionElement).findByTestId('section-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const newSecondSectionId = store.getState().courseOutline.sectionsList[1].id;
|
||||
expect(secondSection.id).toBe(newSecondSectionId);
|
||||
});
|
||||
|
||||
it('check whether section move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get first, second and last section element
|
||||
const {
|
||||
0: firstSection, 1: secondSection, length, [length - 1]: lastSection,
|
||||
} = await findAllByTestId('section-card');
|
||||
|
||||
// find menu button and click on it to open menu in first section
|
||||
const firstMenu = await within(firstSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(firstMenu));
|
||||
// move down option should be enabled in first element
|
||||
expect(
|
||||
await within(firstSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
// move up option should not be enabled in first element
|
||||
expect(
|
||||
await within(firstSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// find menu button and click on it to open menu in second section
|
||||
const secondMenu = await within(secondSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(secondMenu));
|
||||
// both move down & up option should be enabled in second element
|
||||
expect(
|
||||
await within(secondSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
expect(
|
||||
await within(secondSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
|
||||
// find menu button and click on it to open menu in last section
|
||||
const lastMenu = await within(lastSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(lastMenu));
|
||||
// move down option should not be enabled in last element
|
||||
expect(
|
||||
await within(lastSection).findByTestId('section-card-header__menu-move-down-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
// move up option should be enabled in last element
|
||||
expect(
|
||||
await within(lastSection).findByTestId('section-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check whether subsection move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [, secondSubsection] = section.childInfo.children;
|
||||
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[0].id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
|
||||
// move second subsection to first position to test move up option
|
||||
const moveUpButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
|
||||
expect(secondSubsection.id).toBe(firstSubsectionId);
|
||||
|
||||
// move first section back to second position to test move down option
|
||||
const moveDownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const secondSubsectionId = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
|
||||
expect(secondSubsection.id).toBe(secondSubsectionId);
|
||||
});
|
||||
|
||||
it('check whether subsection move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// using second section as second section in mock has 3 subsections
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
// get first, second and last subsection element
|
||||
const {
|
||||
0: firstSubsection,
|
||||
1: secondSubsection,
|
||||
length,
|
||||
[length - 1]: lastSubsection,
|
||||
} = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
|
||||
// find menu button and click on it to open menu in first section
|
||||
const firstMenu = await within(firstSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(firstMenu));
|
||||
// move down option should be enabled in first element
|
||||
expect(
|
||||
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
// move up option should not be enabled in first element
|
||||
expect(
|
||||
await within(firstSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// find menu button and click on it to open menu in second section
|
||||
const secondMenu = await within(secondSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(secondMenu));
|
||||
// both move down & up option should be enabled in second element
|
||||
expect(
|
||||
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
expect(
|
||||
await within(secondSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
|
||||
// find menu button and click on it to open menu in last section
|
||||
const lastMenu = await within(lastSubsection).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(lastMenu));
|
||||
// move down option should not be enabled in last element
|
||||
expect(
|
||||
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-down-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
// move up option should be enabled in last element
|
||||
expect(
|
||||
await within(lastSubsection).findByTestId('subsection-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check whether unit move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get second section -> second subsection -> second unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [, subsection] = section.childInfo.children;
|
||||
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await act(async () => fireEvent.click(expandBtn));
|
||||
const [, secondUnit] = subsection.childInfo.children;
|
||||
const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
// mock api call
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
|
||||
// move second unit to first position to test move up option
|
||||
const moveUpButton = await within(unitElement).findByTestId('unit-card-header__menu-move-up-button');
|
||||
await act(async () => fireEvent.click(moveUpButton));
|
||||
const firstUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[0].id;
|
||||
expect(secondUnit.id).toBe(firstUnitId);
|
||||
|
||||
// move first unit back to second position to test move down option
|
||||
const moveDownButton = await within(subsectionElement).findByTestId('unit-card-header__menu-move-down-button');
|
||||
await act(async () => fireEvent.click(moveDownButton));
|
||||
const secondUnitId = store.getState().courseOutline.sectionsList[1].childInfo.children[1].childInfo.children[1].id;
|
||||
expect(secondUnit.id).toBe(secondUnitId);
|
||||
});
|
||||
|
||||
it('check whether unit move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// using second section -> second subsection as it has 5 units in mock.
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
await act(async () => fireEvent.click(expandBtn));
|
||||
// get first, second and last unit element
|
||||
const {
|
||||
0: firstUnit,
|
||||
1: secondUnit,
|
||||
length,
|
||||
[length - 1]: lastUnit,
|
||||
} = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
// find menu button and click on it to open menu in first section
|
||||
const firstMenu = await within(firstUnit).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(firstMenu));
|
||||
// move down option should be enabled in first element
|
||||
expect(
|
||||
await within(firstUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
// move up option should not be enabled in first element
|
||||
expect(
|
||||
await within(firstUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// find menu button and click on it to open menu in second section
|
||||
const secondMenu = await within(secondUnit).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(secondMenu));
|
||||
// both move down & up option should be enabled in second element
|
||||
expect(
|
||||
await within(secondUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
expect(
|
||||
await within(secondUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
|
||||
// find menu button and click on it to open menu in last section
|
||||
const lastMenu = await within(lastUnit).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(lastMenu));
|
||||
// move down option should not be enabled in last element
|
||||
expect(
|
||||
await within(lastUnit).findByTestId('unit-card-header__menu-move-down-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
// move up option should be enabled in last element
|
||||
expect(
|
||||
await within(lastUnit).findByTestId('unit-card-header__menu-move-up-button'),
|
||||
).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check that new section list is saved when dragged', async () => {
|
||||
const { findAllByRole } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
@@ -865,10 +1100,12 @@ describe('<CourseOutline />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const { queryByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const [sectionElement] = await findAllByTestId('conditional-sortable-element--no-drag-handle')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('conditional-sortable-element--no-drag-handle')).toBeInTheDocument();
|
||||
expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,8 @@ const CardHeader = ({
|
||||
isDisabledEditField,
|
||||
onClickDelete,
|
||||
onClickDuplicate,
|
||||
onClickMoveUp,
|
||||
onClickMoveDown,
|
||||
titleComponent,
|
||||
namePrefix,
|
||||
actions,
|
||||
@@ -112,8 +114,27 @@ const CardHeader = ({
|
||||
{intl.formatMessage(messages.menuDuplicate)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{actions.draggable && (
|
||||
<>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-up-button`}
|
||||
onClick={onClickMoveUp}
|
||||
disabled={!actions.allowMoveUp}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveUp)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-move-down-button`}
|
||||
onClick={onClickMoveDown}
|
||||
disabled={!actions.allowMoveDown}
|
||||
>
|
||||
{intl.formatMessage(messages.menuMoveDown)}
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
{actions.deletable && (
|
||||
<Dropdown.Item
|
||||
className="border-top border-light"
|
||||
data-testid={`${namePrefix}-card-header__menu-delete-button`}
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
@@ -141,6 +162,8 @@ CardHeader.propTypes = {
|
||||
isDisabledEditField: PropTypes.bool.isRequired,
|
||||
onClickDelete: PropTypes.func.isRequired,
|
||||
onClickDuplicate: PropTypes.func.isRequired,
|
||||
onClickMoveUp: PropTypes.func.isRequired,
|
||||
onClickMoveDown: PropTypes.func.isRequired,
|
||||
titleComponent: PropTypes.node.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
actions: PropTypes.shape({
|
||||
@@ -148,6 +171,8 @@ CardHeader.propTypes = {
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
duplicable: PropTypes.bool.isRequired,
|
||||
allowMoveUp: PropTypes.bool,
|
||||
allowMoveDown: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.card.menu.duplicate',
|
||||
defaultMessage: 'Duplicate',
|
||||
},
|
||||
menuMoveUp: {
|
||||
id: 'course-authoring.course-outline.card.menu.moveup',
|
||||
defaultMessage: 'Move up',
|
||||
},
|
||||
menuMoveDown: {
|
||||
id: 'course-authoring.course-outline.card.menu.movedown',
|
||||
defaultMessage: 'Move down',
|
||||
},
|
||||
menuDelete: {
|
||||
id: 'course-authoring.course-outline.card.menu.delete',
|
||||
defaultMessage: 'Delete',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getCurrentItem } from '../data/selectors';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import messages from './messages';
|
||||
|
||||
const PublishModal = ({
|
||||
@@ -19,6 +20,7 @@ const PublishModal = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { displayName, childInfo, category } = useSelector(getCurrentItem);
|
||||
const categoryName = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
|
||||
const children = childInfo?.children || [];
|
||||
|
||||
return (
|
||||
@@ -35,7 +37,9 @@ const PublishModal = ({
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<p className="small">{intl.formatMessage(messages.description, { category })}</p>
|
||||
<p className="small">
|
||||
{intl.formatMessage(messages.description, { category: categoryName })}
|
||||
</p>
|
||||
{children.filter(child => child.hasChanges).map((child) => {
|
||||
let grandChildren = child.childInfo?.children || [];
|
||||
grandChildren = grandChildren.filter(grandChild => grandChild.hasChanges);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Badge, Button, useToggle } from '@edx/paragon';
|
||||
import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { setCurrentItem, setCurrentSection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
@@ -19,6 +20,8 @@ import messages from './messages';
|
||||
const SectionCard = ({
|
||||
section,
|
||||
children,
|
||||
index,
|
||||
canMoveItem,
|
||||
onOpenHighlightsModal,
|
||||
onOpenPublishModal,
|
||||
onOpenConfigureModal,
|
||||
@@ -28,6 +31,7 @@ const SectionCard = ({
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onNewSubsectionSubmit,
|
||||
onOrderChange,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
@@ -54,11 +58,17 @@ const SectionCard = ({
|
||||
published,
|
||||
visibilityState,
|
||||
highlights,
|
||||
actions,
|
||||
actions: sectionActions,
|
||||
isHeaderVisible = true,
|
||||
explanatoryMessage = '',
|
||||
} = section;
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...sectionActions };
|
||||
// add actions to control display of move up & down menu buton.
|
||||
actions.allowMoveUp = canMoveItem(index, -1);
|
||||
actions.allowMoveDown = canMoveItem(index, 1);
|
||||
|
||||
const sectionStatus = getItemStatus({
|
||||
published,
|
||||
visibilityState,
|
||||
@@ -95,6 +105,14 @@ const SectionCard = ({
|
||||
onNewSubsectionSubmit(id);
|
||||
};
|
||||
|
||||
const handleSectionMoveUp = () => {
|
||||
onOrderChange(index, index - 1);
|
||||
};
|
||||
|
||||
const handleSectionMoveDown = () => {
|
||||
onOrderChange(index, index + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
@@ -115,10 +133,12 @@ const SectionCard = ({
|
||||
</TitleButton>
|
||||
);
|
||||
|
||||
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
|
||||
|
||||
return (
|
||||
<ConditionalSortableElement
|
||||
id={id}
|
||||
draggable={actions.draggable}
|
||||
draggable={isDraggable}
|
||||
componentStyle={{
|
||||
padding: '1.75rem',
|
||||
...borderStyle,
|
||||
@@ -141,6 +161,8 @@ const SectionCard = ({
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleSectionMoveUp}
|
||||
onClickMoveDown={handleSectionMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
@@ -166,7 +188,10 @@ const SectionCard = ({
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div data-testid="section-card__subsections" className="item-children section-card__subsections">
|
||||
<div
|
||||
data-testid="section-card__subsections"
|
||||
className={classNames('section-card__subsections', { 'item-children': isDraggable })}
|
||||
>
|
||||
{children}
|
||||
{actions.childAddable && (
|
||||
<Button
|
||||
@@ -220,6 +245,9 @@ SectionCard.propTypes = {
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
isSectionsExpanded: PropTypes.bool.isRequired,
|
||||
onNewSubsectionSubmit: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
canMoveItem: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SectionCard;
|
||||
|
||||
@@ -38,6 +38,9 @@ const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<SectionCard
|
||||
section={section}
|
||||
index="1"
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, useToggle } from '@edx/paragon';
|
||||
import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
@@ -18,12 +19,15 @@ const SubsectionCard = ({
|
||||
section,
|
||||
subsection,
|
||||
children,
|
||||
index,
|
||||
canMoveItem,
|
||||
onOpenPublishModal,
|
||||
onEditSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
onNewUnitSubmit,
|
||||
onOrderChange,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
@@ -37,10 +41,16 @@ const SubsectionCard = ({
|
||||
hasChanges,
|
||||
published,
|
||||
visibilityState,
|
||||
actions,
|
||||
actions: subsectionActions,
|
||||
isHeaderVisible = true,
|
||||
} = subsection;
|
||||
|
||||
// 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 [isExpanded, setIsExpanded] = useState(!isHeaderVisible);
|
||||
const subsectionStatus = getItemStatus({
|
||||
published,
|
||||
@@ -68,6 +78,14 @@ const SubsectionCard = ({
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const handleSubsectionMoveUp = () => {
|
||||
onOrderChange(index, index - 1);
|
||||
};
|
||||
|
||||
const handleSubsectionMoveDown = () => {
|
||||
onOrderChange(index, index + 1);
|
||||
};
|
||||
|
||||
const handleNewButtonClick = () => onNewUnitSubmit(id);
|
||||
|
||||
const titleComponent = (
|
||||
@@ -99,13 +117,17 @@ const SubsectionCard = ({
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const isDraggable = (
|
||||
actions.draggable
|
||||
&& (actions.allowMoveUp || actions.allowMoveDown)
|
||||
&& !(isHeaderVisible === false)
|
||||
);
|
||||
|
||||
return (
|
||||
<ConditionalSortableElement
|
||||
id={id}
|
||||
key={id}
|
||||
draggable={
|
||||
actions.draggable && !(isHeaderVisible === false)
|
||||
}
|
||||
draggable={isDraggable}
|
||||
componentStyle={{
|
||||
background: '#f8f7f6',
|
||||
...borderStyle,
|
||||
@@ -121,6 +143,8 @@ const SubsectionCard = ({
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleSubsectionMoveUp}
|
||||
onClickMoveDown={handleSubsectionMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
@@ -132,7 +156,10 @@ const SubsectionCard = ({
|
||||
/>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div data-testid="subsection-card__units" className="item-children subsection-card__units">
|
||||
<div
|
||||
data-testid="subsection-card__units"
|
||||
className={classNames('subsection-card__units', { 'item-children': isDraggable })}
|
||||
>
|
||||
{children}
|
||||
{actions.childAddable && (
|
||||
<Button
|
||||
@@ -188,6 +215,9 @@ SubsectionCard.propTypes = {
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
onNewUnitSubmit: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
canMoveItem: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SubsectionCard;
|
||||
|
||||
@@ -48,6 +48,9 @@ const renderComponent = (props) => render(
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index="1"
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
|
||||
@@ -15,12 +15,15 @@ const UnitCard = ({
|
||||
unit,
|
||||
subsection,
|
||||
section,
|
||||
index,
|
||||
canMoveItem,
|
||||
onOpenPublishModal,
|
||||
onEditSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
getTitleLink,
|
||||
onOrderChange,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
@@ -33,10 +36,16 @@ const UnitCard = ({
|
||||
hasChanges,
|
||||
published,
|
||||
visibilityState,
|
||||
actions,
|
||||
actions: unitActions,
|
||||
isHeaderVisible = true,
|
||||
} = unit;
|
||||
|
||||
// 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 unitStatus = getItemStatus({
|
||||
published,
|
||||
visibilityState,
|
||||
@@ -59,6 +68,14 @@ const UnitCard = ({
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const handleUnitMoveUp = () => {
|
||||
onOrderChange(index, index - 1);
|
||||
};
|
||||
|
||||
const handleUnitMoveDown = () => {
|
||||
onOrderChange(index, index + 1);
|
||||
};
|
||||
|
||||
const titleComponent = (
|
||||
<TitleLink
|
||||
titleLink={getTitleLink(id)}
|
||||
@@ -88,17 +105,16 @@ const UnitCard = ({
|
||||
}, [savingStatus]);
|
||||
|
||||
if (!isHeaderVisible) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
|
||||
|
||||
return (
|
||||
<ConditionalSortableElement
|
||||
id={id}
|
||||
key={id}
|
||||
draggable={
|
||||
actions.draggable && !(isHeaderVisible === false)
|
||||
}
|
||||
draggable={isDraggable}
|
||||
componentStyle={{
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
@@ -117,6 +133,8 @@ const UnitCard = ({
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleUnitMoveUp}
|
||||
onClickMoveDown={handleUnitMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
@@ -169,6 +187,9 @@ UnitCard.propTypes = {
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
getTitleLink: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
canMoveItem: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default UnitCard;
|
||||
|
||||
@@ -54,6 +54,9 @@ const renderComponent = (props) => render(
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
unit={unit}
|
||||
index="1"
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
savingStatus=""
|
||||
|
||||
Reference in New Issue
Block a user