From d31be65013b666fe87685ec00d0476d1d64ac929 Mon Sep 17 00:00:00 2001 From: Sid Verma Date: Tue, 16 Jan 2024 20:28:19 +0530 Subject: [PATCH] feat: Add drag-n-drop support to course unit, refactor tests. --- src/course-outline/CourseOutline.jsx | 64 ++++++--- src/course-outline/CourseOutline.test.jsx | 164 ++++++++++++++-------- src/course-outline/data/api.js | 22 +++ src/course-outline/data/slice.js | 10 ++ src/course-outline/data/thunk.js | 23 +++ src/course-outline/hooks.jsx | 6 + src/course-outline/unit-card/UnitCard.jsx | 4 +- 7 files changed, 220 insertions(+), 73 deletions(-) diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 86894f74b0..fb0f3e9564 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -93,6 +93,7 @@ const CourseOutline = ({ courseId }) => { getUnitUrl, handleSectionDragAndDrop, handleSubsectionDragAndDrop, + handleUnitDragAndDrop, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); @@ -125,6 +126,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 updatedSubsection = [...subsection.childInfo.children]; + updatedSubsection[subsectionIndex] = subsection; + section.childInfo.children = updatedSubsection; + 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]); @@ -199,7 +221,7 @@ const CourseOutline = ({ courseId }) => { {sections.length ? ( <> - {sections.map((section, index) => ( + {sections.map((section, sectionIndex) => ( { > - {section.childInfo.children.map((subsection) => ( + {section.childInfo.children.map((subsection, subsectionIndex) => ( { onDuplicateSubmit={handleDuplicateSubsectionSubmit} onNewUnitSubmit={handleNewUnitSubmit} > - {subsection.childInfo.children.map((unit) => ( - - ))} + {subsection.childInfo && ( + + {subsection.childInfo.children.map((unit) => ( + + ))} + + )} ))} diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index ae5d403e25..e286bb9f94 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -18,6 +18,7 @@ import { getCourseItemApiUrl, getXBlockBaseApiUrl, getChapterBlockApiUrl, + getSequentialBlockApiUrl, } from './data/api'; import { RequestStatus } from '../data/constants'; import { @@ -633,108 +634,161 @@ describe('', () => { }); it('check that new section list is saved when dragged', async () => { - const { getAllByRole } = render(); - + const { findAllByRole } = render(); const courseBlockId = courseOutlineIndexMock.courseStructure.id; - await waitFor(async () => { - const sectionsDraggers = await getAllByRole('button', { name: 'Drag to reorder' }); + const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = sectionsDraggers[7]; - axiosMock - .onPut(getCourseBlockApiUrl(courseBlockId)) - .reply(200, { dummy: 'value' }); + axiosMock + .onPut(getCourseBlockApiUrl(courseBlockId)) + .reply(200, { dummy: 'value' }); + + const section1 = store.getState().courseOutline.sectionsList[0].id; - const section1 = store.getState().courseOutline.sectionsList[0].id; - const draggableButton = sectionsDraggers[7]; + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { 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(); - + const { findAllByRole } = render(); const courseBlockId = courseOutlineIndexMock.courseStructure.id; - await waitFor(async () => { - const sectionsDraggers = await getAllByRole('button', { name: 'Drag to reorder' }); + const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = sectionsDraggers[6]; - axiosMock - .onPut(getCourseBlockApiUrl(courseBlockId)) - .reply(500); + axiosMock + .onPut(getCourseBlockApiUrl(courseBlockId)) + .reply(500); + + const section1 = store.getState().courseOutline.sectionsList[0].id; - const section1 = store.getState().courseOutline.sectionsList[0].id; - const draggableButton = sectionsDraggers[6]; + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { 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(); const courseBlockId = courseOutlineIndexMock.courseStructure.id; - await waitFor(async () => { - const [section] = await findAllByTestId('section-card'); - const subsectionsDraggers = within(section).getAllByRole('button', { name: 'Drag to reorder' }); + const [sectionElement] = await findAllByTestId('section-card'); + const section = store.getState().courseOutline.sectionsList[0]; + const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = subsectionsDraggers[1]; - axiosMock - .onPut(getChapterBlockApiUrl(courseBlockId, store.getState().courseOutline.sectionsList[0].id)) - .reply(200, { dummy: 'value' }); + axiosMock + .onPut(getChapterBlockApiUrl(courseBlockId, section.id)) + .reply(200, { dummy: 'value' }); - const subsection1 = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id; + const subsection1 = section.childInfo.children[0].id; - // Move the second subsection up - const draggableButton = subsectionsDraggers[1]; + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { 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(); const courseBlockId = courseOutlineIndexMock.courseStructure.id; - await waitFor(async () => { - const [section] = await findAllByTestId('section-card'); - const subsectionsDraggers = within(section).getAllByRole('button', { name: 'Drag to reorder' }); + const [sectionElement] = await findAllByTestId('section-card'); + const section = store.getState().courseOutline.sectionsList[0]; + const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); + const draggableButton = subsectionsDraggers[1]; - axiosMock - .onPut(getChapterBlockApiUrl(courseBlockId, store.getState().courseOutline.sectionsList[0].id)) - .reply(500); + axiosMock + .onPut(getChapterBlockApiUrl(courseBlockId, section.id)) + .reply(500); - const subsection1 = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id; + const subsection1 = section.childInfo.children[0].id; - // Move the second subsection up - const draggableButton = subsectionsDraggers[1]; + fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); + await waitFor(async () => { 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); + }); + + it('check that new unit list is saved when dragged', async () => { + const { findAllByTestId } = render(); + const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const subsectionElement = (await findAllByTestId('subsection-card'))[3]; + const subsection = store.getState().courseOutline.sectionsList[1].childInfo.children[0]; + 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(getSequentialBlockApiUrl(courseBlockId, 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); + }); - const subsection1New = store.getState().courseOutline.sectionsList[0].childInfo.children[0].id; - expect(subsection1).toBe(subsection1New); + it('check that new unit list is restored to original order when API call fails', async () => { + const { findAllByTestId } = render(); + const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const subsectionElement = (await findAllByTestId('subsection-card'))[3]; + const subsection = store.getState().courseOutline.sectionsList[1].childInfo.children[0]; + 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(getSequentialBlockApiUrl(courseBlockId, 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 () => { diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 7cd0380bd1..ce605bd697 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -30,6 +30,12 @@ export const getChapterBlockApiUrl = (courseId, chapterId) => { return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@chapter+block@${formattedChapterId}`; }; +export const getSequentialBlockApiUrl = (courseId, unitId) => { + const formattedCourseId = courseId.split('course-v1:')[1]; + const formattedUnitId = unitId.split('@').slice(-1)[0]; + return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@sequential+block@${formattedUnitId}`; +}; + export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`; export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; @@ -333,3 +339,19 @@ export async function setSubsectionOrderList(courseId, sectionId, children) { return data; } + +/** + * Set order for the list of the units + * @param {string} courseId + * @param {string} subsectionId + * @param {Array} children list of unit id's + * @returns {Promise} +*/ +export async function setUnitOrderList(courseId, subsectionId, children) { + const { data } = await getAuthenticatedHttpClient() + .put(getSequentialBlockApiUrl(courseId, subsectionId), { + children, + }); + + return data; +} diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index e5f2ec5928..d790fbf2aa 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -101,6 +101,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; }, @@ -190,6 +199,7 @@ export const { duplicateSection, reorderSectionList, reorderSubsectionList, + reorderUnitList, } = slice.actions; export const { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 8ec7561e33..a906dce5f8 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -25,6 +25,7 @@ import { updateCourseSectionHighlights, setSectionOrderList, setSubsectionOrderList, + setUnitOrderList, } from './api'; import { addSection, @@ -45,6 +46,7 @@ import { duplicateSection, reorderSectionList, reorderSubsectionList, + reorderUnitList, } from './slice'; export function fetchCourseOutlineIndexQuery(courseId) { @@ -461,3 +463,24 @@ export function setSubsectionOrderListQuery(courseId, sectionId, subsectionListI } }; } + +export function setUnitOrderListQuery(courseId, sectionId, subsectionId, unitListIds, restoreCallback) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await setUnitOrderList(courseId, 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 })); + } + }; +} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 3b93803f28..07d6a2220f 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -43,6 +43,7 @@ import { configureCourseSectionQuery, setSectionOrderListQuery, setSubsectionOrderListQuery, + setUnitOrderListQuery, } from './data/thunk'; const useCourseOutline = ({ courseId }) => { @@ -193,6 +194,10 @@ const useCourseOutline = ({ courseId }) => { dispatch(setSubsectionOrderListQuery(courseId, sectionId, subsectionListIds, restoreCallback)); }; + const handleUnitDragAndDrop = (sectionId, subsectionId, unitListIds, restoreCallback) => { + dispatch(setUnitOrderListQuery(courseId, sectionId, subsectionId, unitListIds, restoreCallback)); + }; + useEffect(() => { dispatch(fetchCourseOutlineIndexQuery(courseId)); dispatch(fetchCourseBestPracticesQuery({ courseId })); @@ -255,6 +260,7 @@ const useCourseOutline = ({ courseId }) => { handleNewUnitSubmit, handleSectionDragAndDrop, handleSubsectionDragAndDrop, + handleUnitDragAndDrop, }; }; diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index b6cc2dff11..04e075fa58 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -96,7 +96,9 @@ const UnitCard = ({