Skip to content

Commit

Permalink
feat: Add drag-n-drop support to course unit, refactor tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
Sid Verma committed Jan 16, 2024
1 parent 7ed379b commit d31be65
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 73 deletions.
64 changes: 47 additions & 17 deletions src/course-outline/CourseOutline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const CourseOutline = ({ courseId }) => {
getUnitUrl,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
} = useCourseOutline({ courseId });

const [sections, setSections] = useState(sectionsList);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -199,7 +221,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}
Expand All @@ -216,10 +238,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}
Expand All @@ -231,20 +253,28 @@ 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}
/>
))}
{subsection.childInfo && (
<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>
Expand Down
164 changes: 109 additions & 55 deletions src/course-outline/CourseOutline.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getCourseItemApiUrl,
getXBlockBaseApiUrl,
getChapterBlockApiUrl,
getSequentialBlockApiUrl,
} from './data/api';
import { RequestStatus } from '../data/constants';
import {
Expand Down Expand Up @@ -633,108 +634,161 @@ 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;
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(<RootWrapper />);

const { findAllByRole } = render(<RootWrapper />);
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(<RootWrapper />);

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(<RootWrapper />);

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(<RootWrapper />);
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(<RootWrapper />);
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 () => {
Expand Down
22 changes: 22 additions & 0 deletions src/course-outline/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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<string>} children list of unit id's
* @returns {Promise<Object>}
*/
export async function setUnitOrderList(courseId, subsectionId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getSequentialBlockApiUrl(courseId, subsectionId), {
children,
});

return data;
}
10 changes: 10 additions & 0 deletions src/course-outline/data/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -190,6 +199,7 @@ export const {
duplicateSection,
reorderSectionList,
reorderSubsectionList,
reorderUnitList,
} = slice.actions;

export const {
Expand Down
Loading

0 comments on commit d31be65

Please sign in to comment.