feat: Add drag-n-drop support to course unit, refactor tests.

chore: address review feedback
This commit is contained in:
Sid Verma
2024-01-16 20:28:19 +05:30
committed by Kristin Aoki
parent 0e829974ef
commit d2f63b8b16
7 changed files with 206 additions and 97 deletions

View File

@@ -94,6 +94,7 @@ const CourseOutline = ({ courseId }) => {
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
handleUnitDragAndDrop,
} = useCourseOutline({ courseId });
const [sections, setSections] = useState(sectionsList);
@@ -126,6 +127,27 @@ const CourseOutline = ({ courseId }) => {
});
};
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);
});
};
useEffect(() => {
setSections(sectionsList);
}, [sectionsList]);
@@ -201,7 +223,7 @@ const CourseOutline = ({ courseId }) => {
{sections.length ? (
<>
<DraggableList itemList={sections} setState={setSections} updateOrder={finalizeSectionOrder}>
{sections.map((section, index) => (
{sections.map((section, sectionIndex) => (
<SectionCard
id={section.id}
key={section.id}
@@ -218,10 +240,10 @@ const CourseOutline = ({ courseId }) => {
>
<DraggableList
itemList={section.childInfo.children}
setState={setSubsection(index)}
setState={setSubsection(sectionIndex)}
updateOrder={finalizeSubsectionOrder(section)}
>
{section.childInfo.children.map((subsection) => (
{section.childInfo.children.map((subsection, subsectionIndex) => (
<SubsectionCard
key={subsection.id}
section={section}
@@ -233,20 +255,26 @@ const CourseOutline = ({ courseId }) => {
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onNewUnitSubmit={handleNewUnitSubmit}
>
{subsection.childInfo.children.map((unit) => (
<UnitCard
key={unit.id}
unit={unit}
subsection={subsection}
section={section}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
/>
))}
<DraggableList
itemList={subsection.childInfo.children}
setState={setUnit(sectionIndex, subsectionIndex)}
updateOrder={finalizeUnitOrder(section, subsection)}
>
{subsection.childInfo.children.map((unit) => (
<UnitCard
key={unit.id}
unit={unit}
subsection={subsection}
section={section}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
/>
))}
</DraggableList>
</SubsectionCard>
))}
</DraggableList>

View File

@@ -17,7 +17,6 @@ import {
getCourseBlockApiUrl,
getCourseItemApiUrl,
getXBlockBaseApiUrl,
getChapterBlockApiUrl,
} from './data/api';
import { RequestStatus } from '../data/constants';
import {
@@ -688,108 +687,157 @@ describe('<CourseOutline />', () => {
});
it('check that new section list is saved when dragged', async () => {
const { getAllByRole } = render(<RootWrapper />);
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 () => {
const sectionsDraggers = await getAllByRole('button', { name: 'Drag to reorder' });
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.reply(200, { dummy: 'value' });
const section1 = store.getState().courseOutline.sectionsList[0].id;
const draggableButton = sectionsDraggers[7];
fireEvent.keyDown(draggableButton, { code: 'Space' });
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await act(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);
});
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 { getAllByRole } = render(<RootWrapper />);
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 () => {
const sectionsDraggers = await getAllByRole('button', { name: 'Drag to reorder' });
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.reply(500);
const section1 = store.getState().courseOutline.sectionsList[0].id;
const draggableButton = sectionsDraggers[6];
fireEvent.keyDown(draggableButton, { code: 'Space' });
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await act(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);
});
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 />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const [sectionElement] = await findAllByTestId('section-card');
const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1];
axiosMock
.onPut(getCourseItemApiUrl(section.id))
.reply(200, { dummy: 'value' });
const subsection1 = section.childInfo.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
const [section] = await findAllByTestId('section-card');
const subsectionsDraggers = within(section).getAllByRole('button', { name: 'Drag to reorder' });
axiosMock
.onPut(getChapterBlockApiUrl(courseBlockId, store.getState().courseOutline.sectionsList[0].id))
.reply(200, { dummy: 'value' });
const subsection1 = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
// Move the second subsection up
const draggableButton = subsectionsDraggers[1];
fireEvent.keyDown(draggableButton, { code: 'Space' });
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await act(async () => fireEvent.keyDown(draggableButton, { code: 'Space' }));
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
const subsection2 = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
expect(subsection1).toBe(subsection2);
});
const subsection2 = store.getState().courseOutline.sectionsList[0].childInfo.children[1].id;
expect(subsection1).toBe(subsection2);
});
it('check that new subsection list is restored to original order when API call fails', async () => {
const { findAllByTestId } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const [sectionElement] = await findAllByTestId('section-card');
const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1];
axiosMock
.onPut(getCourseItemApiUrl(section.id))
.reply(500);
const subsection1 = section.childInfo.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
const [section] = await findAllByTestId('section-card');
const subsectionsDraggers = within(section).getAllByRole('button', { name: 'Drag to reorder' });
axiosMock
.onPut(getChapterBlockApiUrl(courseBlockId, store.getState().courseOutline.sectionsList[0].id))
.reply(500);
const subsection1 = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
// Move the second subsection up
const draggableButton = subsectionsDraggers[1];
fireEvent.keyDown(draggableButton, { code: 'Space' });
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await act(async () => fireEvent.keyDown(draggableButton, { code: 'Space' }));
const saveStatus = store.getState().courseOutline.savingStatus;
expect(saveStatus).toEqual(RequestStatus.FAILED);
const subsection1New = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
expect(subsection1).toBe(subsection1New);
});
const subsection1New = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id;
expect(subsection1).toBe(subsection1New);
});
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;
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];
axiosMock
.onPut(getCourseItemApiUrl(subsection.id))
.reply(200, { dummy: 'value' });
const unit1 = subsection.childInfo.children[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 unit2 = store.getState().courseOutline.sectionsList[1].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;
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];
axiosMock
.onPut(getCourseItemApiUrl(subsection.id))
.reply(500);
const unit1 = subsection.childInfo.children[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 unit1New = store.getState().courseOutline.sectionsList[1].childInfo.children[0].childInfo.children[0].id;
expect(unit1).toBe(unit1New);
});
it('check that drag handle is not visible for non-draggable sections', async () => {

View File

@@ -24,12 +24,6 @@ export const getCourseBlockApiUrl = (courseId) => {
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`;
};
export const getChapterBlockApiUrl = (courseId, chapterId) => {
const formattedCourseId = courseId.split('course-v1:')[1];
const formattedChapterId = chapterId.split('@').slice(-1)[0];
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@chapter+block@${formattedChapterId}`;
};
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
@@ -320,14 +314,13 @@ export async function setSectionOrderList(courseId, children) {
/**
* Set order for the list of the subsections
* @param {string} courseId
* @param {string} sectionId
* @param {string} itemId Subsection or unit ID
* @param {Array<string>} children list of sections id's
* @returns {Promise<Object>}
*/
export async function setSubsectionOrderList(courseId, sectionId, children) {
export async function setCourseItemOrderList(itemId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getChapterBlockApiUrl(courseId, sectionId), {
.put(getCourseItemApiUrl(itemId), {
children,
});

View File

@@ -104,6 +104,15 @@ const slice = createSlice({
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;
},
@@ -193,6 +202,7 @@ export const {
duplicateSection,
reorderSectionList,
reorderSubsectionList,
reorderUnitList,
} = slice.actions;
export const {

View File

@@ -25,7 +25,7 @@ import {
updateCourseSectionHighlights,
setSectionOrderList,
setVideoSharingOption,
setSubsectionOrderList,
setCourseItemOrderList,
} from './api';
import {
addSection,
@@ -46,6 +46,7 @@ import {
duplicateSection,
reorderSectionList,
reorderSubsectionList,
reorderUnitList,
} from './slice';
export function fetchCourseOutlineIndexQuery(courseId) {
@@ -468,13 +469,13 @@ export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallba
};
}
export function setSubsectionOrderListQuery(courseId, sectionId, subsectionListIds, restoreCallback) {
export function setSubsectionOrderListQuery(sectionId, subsectionListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setSubsectionOrderList(courseId, sectionId, subsectionListIds).then(async (result) => {
await setCourseItemOrderList(sectionId, subsectionListIds).then(async (result) => {
if (result) {
dispatch(reorderSubsectionList({ sectionId, subsectionListIds }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
@@ -488,3 +489,24 @@ export function setSubsectionOrderListQuery(courseId, sectionId, subsectionListI
}
};
}
export function setUnitOrderListQuery(sectionId, subsectionId, 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());
}
});
} catch (error) {
restoreCallback();
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -44,6 +44,7 @@ import {
setSectionOrderListQuery,
setVideoSharingOptionQuery,
setSubsectionOrderListQuery,
setUnitOrderListQuery,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -191,13 +192,17 @@ const useCourseOutline = ({ courseId }) => {
};
const handleSubsectionDragAndDrop = (sectionId, subsectionListIds, restoreCallback) => {
dispatch(setSubsectionOrderListQuery(courseId, 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));
};
useEffect(() => {
dispatch(fetchCourseOutlineIndexQuery(courseId));
dispatch(fetchCourseBestPracticesQuery({ courseId }));
@@ -261,6 +266,7 @@ const useCourseOutline = ({ courseId }) => {
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
handleUnitDragAndDrop,
};
};

View File

@@ -96,7 +96,9 @@ const UnitCard = ({
<ConditionalSortableElement
id={id}
key={id}
draggable={false} // update to {actions.draggable} when unit drag-n-drop is implemented
draggable={
actions.draggable && !(isHeaderVisible === false)
}
componentStyle={{
background: '#fdfdfd',
...borderStyle,