feat: Add drag and drop support to subsections

feat: Update tests, fix bugs in drag and drop elements

chore: address review feedback
This commit is contained in:
Sid Verma
2023-12-23 03:29:38 +05:30
committed by Kristin Aoki
parent 3842b046cd
commit 70b4795650
8 changed files with 326 additions and 77 deletions

View File

@@ -91,13 +91,14 @@ const CourseOutline = ({ courseId }) => {
handleNewSubsectionSubmit,
handleNewUnitSubmit,
getUnitUrl,
handleDragNDrop,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
} = useCourseOutline({ courseId });
const [sections, setSections] = useState(sectionsList);
const initialSections = [...sectionsList];
let initialSections = [...sectionsList];
const {
isShow: isShowProcessingNotification,
@@ -105,7 +106,22 @@ const CourseOutline = ({ courseId }) => {
} = useSelector(getProcessingNotification);
const finalizeSectionOrder = () => (newSections) => {
handleDragNDrop(newSections.map((section) => section.id), () => {
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);
});
};
@@ -184,19 +200,19 @@ const CourseOutline = ({ courseId }) => {
{sections.length ? (
<>
<DraggableList itemList={sections} setState={setSections} updateOrder={finalizeSectionOrder}>
{sections.map((section) => (
{sections.map((section, index) => (
<SortableItem
id={section.id}
key={section.id}
componentStyle={{
background: 'white',
borderRadius: '6px',
padding: '1.75rem',
marginBottom: '1.5rem',
boxShadow: '0px 1px 5px #ADADAD',
boxShadow: '0 0 .125rem rgba(0, 0, 0, .15), 0 0 .25rem rgba(0, 0, 0, .15)',
}}
>
<SectionCard
id={section.id}
key={section.id}
section={section}
savingStatus={savingStatus}
@@ -209,34 +225,51 @@ const CourseOutline = ({ courseId }) => {
isSectionsExpanded={isSectionsExpanded}
onNewSubsectionSubmit={handleNewSubsectionSubmit}
>
{section.childInfo.children.map((subsection) => (
<SubsectionCard
key={subsection.id}
section={section}
subsection={subsection}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onNewUnitSubmit={handleNewUnitSubmit}
>
{subsection.childInfo.children.map((unit) => (
<UnitCard
key={unit.id}
unit={unit}
subsection={subsection}
<DraggableList
itemList={section.childInfo.children}
setState={setSubsection(index)}
updateOrder={finalizeSubsectionOrder(section)}
>
{section.childInfo.children.map((subsection) => (
<SortableItem
id={subsection.id}
key={subsection.id}
componentStyle={{
background: '#f8f7f6',
padding: '1rem 1.5rem',
marginBottom: '1.5rem',
boxShadow: '0 0 .125rem rgba(0, 0, 0, .15), 0 0 .25rem rgba(0, 0, 0, .15)',
}}
>
<SubsectionCard
key={subsection.id}
section={section}
subsection={subsection}
savingStatus={savingStatus}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
/>
))}
</SubsectionCard>
))}
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}
/>
))}
</SubsectionCard>
</SortableItem>
))}
</DraggableList>
</SectionCard>
</SortableItem>
))}

View File

@@ -17,14 +17,15 @@ import {
getCourseBlockApiUrl,
getCourseItemApiUrl,
getXBlockBaseApiUrl,
getChapterBlockApiUrl,
} from './data/api';
import { RequestStatus } from '../data/constants';
import {
configureCourseSectionQuery,
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery,
updateCourseSectionHighlightsQuery,
setSectionOrderListQuery,
} from './data/thunk';
import initializeStore from '../store';
import {
@@ -210,7 +211,7 @@ describe('<CourseOutline />', () => {
const { findAllByTestId } = render(<RootWrapper />);
const [section] = await findAllByTestId('section-card');
let subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(1);
expect(subsections.length).toBe(2);
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
top: 0,
bottom: 4000,
@@ -230,7 +231,7 @@ describe('<CourseOutline />', () => {
});
subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(2);
expect(subsections.length).toBe(3);
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
});
@@ -525,7 +526,7 @@ describe('<CourseOutline />', () => {
// check unit
await checkDuplicateBtn(unit, subsectionElement, unitElement, 'unit', 2);
// check subsection
await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 2);
await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 3);
// check section
await checkDuplicateBtn(section, null, sectionElement, 'section', 5);
});
@@ -689,46 +690,108 @@ describe('<CourseOutline />', () => {
});
});
it('check section list is ordered successfully', async () => {
const { getAllByTestId } = render(<RootWrapper />);
it('check that new section list is saved when dragged', async () => {
const { getAllByRole } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
let { children } = courseOutlineIndexMock.courseStructure.childInfo;
children = children.splice(2, 0, children.splice(0, 1)[0]);
await waitFor(async () => {
const sectionsDraggers = await getAllByRole('button', { name: 'Drag to reorder' });
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId), { children })
.reply(200, { dummy: 'value' });
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.reply(200, { dummy: 'value' });
await executeThunk(setSectionOrderListQuery(courseBlockId, children, () => {}), store.dispatch);
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' }));
await waitFor(() => {
expect(getAllByTestId('section-card')).toHaveLength(4);
const newSections = getAllByTestId('section-card');
for (let i; i < children.length; i++) {
expect(children[i].id === newSections[i].id);
}
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 { getAllByTestId } = render(<RootWrapper />);
const { getAllByRole } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
const { children } = courseOutlineIndexMock.courseStructure.childInfo;
const newChildren = children.splice(2, 0, children.splice(0, 1)[0]);
await waitFor(async () => {
const sectionsDraggers = await getAllByRole('button', { name: 'Drag to reorder' });
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId), { children })
.reply(500);
axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId))
.reply(500);
await executeThunk(setSectionOrderListQuery(courseBlockId, undefined, () => children), store.dispatch);
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' }));
await waitFor(() => {
expect(getAllByTestId('section-card')).toHaveLength(4);
const newSections = getAllByTestId('section-card');
for (let i; i < children.length; i++) {
expect(children[i].id === newSections[i].id);
expect(newChildren[i].id !== newSections[i].id);
}
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 />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
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);
});
});
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' });
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);
});
});
});

View File

@@ -318,6 +318,107 @@ module.exports = {
selectedGroupsLabel: '',
},
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f',
display_name: 'Sample Subsection',
category: 'sequential',
has_children: true,
edited_on: 'Dec 05, 2023 at 10:35 UTC',
published: true,
published_on: 'Dec 05, 2023 at 10:35 UTC',
studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%407f75de8dcc261249250b71925f49810f',
released_to_students: true,
release_date: 'Feb 05, 2013 at 05:00 UTC',
visibility_state: 'live',
has_explicit_staff_lock: false,
start: '2013-02-05T05:00:00Z',
graded: false,
due_date: '',
due: null,
relative_weeks_due: null,
format: null,
course_graders: [
'Homework',
'Exam',
],
has_changes: false,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatory_message: null,
group_access: {},
user_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
show_correctness: 'always',
hide_after_due: false,
is_proctored_exam: false,
was_exam_ever_linked_with_external: false,
online_proctoring_rules: '',
is_practice_exam: false,
is_onboarding_exam: false,
is_time_limited: false,
exam_review_rules: '',
default_time_limit_minutes: null,
proctoring_exam_configuration_link: null,
supports_onboarding: false,
show_review_rules: true,
child_info: {
category: 'vertical',
display_name: 'Unit',
children: [],
},
ancestor_has_staff_lock: false,
staff_only_message: false,
enable_copy_paste_units: false,
has_partition_group_components: false,
user_partition_info: {
selectable_partitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 2,
name: 'Verified Certificate',
selected: false,
deleted: false,
},
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
},
],
},
ancestorHasStaffLock: false,

View File

@@ -24,6 +24,12 @@ 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}`;
@@ -312,6 +318,22 @@ export async function setSectionOrderList(courseId, children) {
return data;
}
/**
* Set order for the list of the subsections
* @param {string} courseId
* @param {string} sectionId
* @param {Array<string>} children list of sections id's
* @returns {Promise<Object>}
*/
export async function setSubsectionOrderList(courseId, sectionId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getChapterBlockApiUrl(courseId, sectionId), {
children,
});
return data;
}
/**
* Set video sharing setting
* @param {string} courseId

View File

@@ -85,6 +85,13 @@ 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];
},
setCurrentSection: (state, { payload }) => {
state.currentSection = payload;
},
@@ -172,6 +179,7 @@ export const {
deleteUnit,
duplicateSection,
reorderSectionList,
reorderSubsectionList,
} = slice.actions;
export const {

View File

@@ -25,6 +25,7 @@ import {
updateCourseSectionHighlights,
setSectionOrderList,
setVideoSharingOption,
setSubsectionOrderList,
} from './api';
import {
addSection,
@@ -43,6 +44,7 @@ import {
deleteUnit,
duplicateSection,
reorderSectionList,
reorderSubsectionList,
} from './slice';
export function fetchCourseOutlineIndexQuery(courseId) {
@@ -442,15 +444,36 @@ export function addNewUnitQuery(parentLocator, callback) {
};
}
export function setSectionOrderListQuery(courseId, newListId, restoreCallback) {
export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setSectionOrderList(courseId, newListId).then(async (result) => {
await setSectionOrderList(courseId, sectionListIds).then(async (result) => {
if (result) {
dispatch(reorderSectionList(newListId));
dispatch(reorderSectionList(sectionListIds));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
restoreCallback();
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function setSubsectionOrderListQuery(courseId, 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) => {
if (result) {
dispatch(reorderSubsectionList({ sectionId, subsectionListIds }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}

View File

@@ -42,6 +42,7 @@ import {
configureCourseSectionQuery,
setSectionOrderListQuery,
setVideoSharingOptionQuery,
setSubsectionOrderListQuery,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -183,8 +184,12 @@ const useCourseOutline = ({ courseId }) => {
dispatch(duplicateUnitQuery(currentItem.id, currentSubsection.id, currentSection.id));
};
const handleDragNDrop = (newListId, restoreCallback) => {
dispatch(setSectionOrderListQuery(courseId, newListId, restoreCallback));
const handleSectionDragAndDrop = (sectionListIds, restoreCallback) => {
dispatch(setSectionOrderListQuery(courseId, sectionListIds, restoreCallback));
};
const handleSubsectionDragAndDrop = (sectionId, subsectionListIds, restoreCallback) => {
dispatch(setSubsectionOrderListQuery(courseId, sectionId, subsectionListIds, restoreCallback));
};
const handleVideoSharingOptionChange = (value) => {
@@ -250,7 +255,8 @@ const useCourseOutline = ({ courseId }) => {
getUnitUrl,
openUnitPage,
handleNewUnitSubmit,
handleDragNDrop,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
};
};

View File

@@ -1,16 +1,9 @@
.subsection-card {
@include pgn-box-shadow(1, "centered");
padding: $spacer 2rem;
margin-bottom: 1.5rem;
background: $light-200;
.subsection-card__content {
margin: $spacer;
}
flex-grow: 1;
.subsection-card__units {
padding-top: $spacer;
margin-top: $spacer;
margin-right: -2.75rem;
}
.item-card-header__badge-status {