Skip to content

Commit

Permalink
Add lesson draggability
Browse files Browse the repository at this point in the history
  • Loading branch information
mslwang committed Jul 19, 2022
1 parent 4fe33a7 commit 7f5595f
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 71 deletions.
122 changes: 67 additions & 55 deletions frontend/src/components/module-editor/LessonItem.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,88 @@
import React, { useState } from "react";
import { Button, IconButton, Flex } from "@chakra-ui/react";
import { EditIcon, DeleteIcon } from "@chakra-ui/icons";
import { Draggable } from "react-beautiful-dnd";
import { ReactComponent as DragHandleIconSvg } from "../../assets/DragHandle.svg";

interface OptionsProps {
id: string;
text: string;
isFocused: boolean;
setFocus: () => void;
index: number;
}

/* eslint-disable react/jsx-props-no-spreading */

const LessonItem = ({
text = "",
id,
text,
isFocused,
setFocus,
index,
}: OptionsProps): React.ReactElement => {
const [isHovered, setIsHovered] = useState(false);

return (
<>
<Button
as="div"
role="button"
display="inline-flex"
alignItems="space-between"
justifyContent="center"
flexDirection="column"
tabIndex={0}
onClick={setFocus}
variant="unstyled"
borderLeftColor={isFocused ? "brand.royal" : undefined}
borderLeftWidth={isFocused ? "5px" : undefined}
borderRadius={isFocused ? "0" : undefined}
bg={isFocused ? "background.light" : undefined}
textAlign="left"
pl={isFocused ? "6px" : "10px"}
minH="55px"
w="100%"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Flex align="center" justify="space-between" pr="8px">
<Flex align="center">
<IconButton
visibility={isHovered ? "visible" : "hidden"}
aria-label="Drag Lesson"
variant="unstyled"
size="xs"
icon={<DragHandleIconSvg />}
/>
<p style={{ marginLeft: "10px" }}>{text}</p>
</Flex>
<Flex align="center" pb="5px">
<IconButton
visibility={isHovered ? "visible" : "hidden"}
aria-label="Edit Lesson"
variant="unstyled"
fontSize="18px"
size="sm"
icon={<EditIcon />}
/>
<IconButton
visibility={isHovered ? "visible" : "hidden"}
aria-label="Delete Lesson"
variant="unstyled"
fontSize="18px"
size="sm"
icon={<DeleteIcon />}
/>
<Draggable key={id} draggableId={id} index={index}>
{(provided) => (
<Button
as="div"
role="button"
display="inline-flex"
alignItems="space-between"
justifyContent="center"
flexDirection="column"
tabIndex={0}
onClick={setFocus}
variant="unstyled"
borderLeftColor={isFocused ? "brand.royal" : undefined}
borderLeftWidth={isFocused ? "5px" : undefined}
borderRadius={isFocused ? "0" : undefined}
bg={isFocused ? "background.light" : undefined}
textAlign="left"
pl={isFocused ? "6px" : "10px"}
minH="55px"
w="100%"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
{...provided.draggableProps}
style={provided.draggableProps.style}
ref={provided.innerRef}
>
<Flex align="center" justify="space-between" pr="8px">
<Flex align="center">
<IconButton
visibility={isHovered ? "visible" : "hidden"}
aria-label="Drag Lesson"
variant="unstyled"
size="xs"
icon={<DragHandleIconSvg />}
{...provided.dragHandleProps}
/>
<p style={{ marginLeft: "10px" }}>{text}</p>
</Flex>
<Flex align="center" pb="5px">
<IconButton
visibility={isHovered ? "visible" : "hidden"}
aria-label="Edit Lesson"
variant="unstyled"
fontSize="18px"
size="sm"
icon={<EditIcon />}
/>
<IconButton
visibility={isHovered ? "visible" : "hidden"}
aria-label="Delete Lesson"
variant="unstyled"
fontSize="18px"
size="sm"
icon={<DeleteIcon />}
/>
</Flex>
</Flex>
</Flex>
</Button>
</>
</Button>
)}
</Draggable>
);
};

Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/module-editor/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ const Sidebar = (): React.ReactElement => {

const saveChanges = async (changeObj: EditorChangeStatuses) => {
Object.entries(changeObj).forEach(async ([doc_id, action]) => {
if (action === "COURSE-UPDATE") {
// Nothing happens because the updateCourse mutation is already called at the end anyway
return;
}
const changedLesson = formatLessonRequest(state.lessons[doc_id]);
switch (action) {
case "CREATE":
Expand All @@ -152,9 +156,9 @@ const Sidebar = (): React.ReactElement => {
default:
break;
}
updateCourse({
variables: { id: changedLesson.course, course: state.course },
});
});
updateCourse({
variables: { id: courseID, course: state.course },
});
state.hasChanged = {};
};
Expand Down
64 changes: 52 additions & 12 deletions frontend/src/components/module-editor/SideBarModuleOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,41 @@ import { AddIcon } from "@chakra-ui/icons";
import React, { useContext, useState } from "react";
import { useParams } from "react-router-dom";

import { DragDropContext, Droppable, DropResult } from "react-beautiful-dnd";
import EditorContext from "../../contexts/ModuleEditorContext";
import { ModuleEditorParams } from "../../types/ModuleEditorTypes";
import {
EditorContextAction,
ModuleEditorParams,
} from "../../types/ModuleEditorTypes";
import { Modal } from "../common/Modal";
import { TextInput } from "../common/TextInput";

import LessonItem from "./LessonItem";

// Copy drag implementation based on https://github.com/atlassian/react-beautiful-dnd/issues/216#issuecomment-423708497
const onDragEnd = (
dispatch: React.Dispatch<EditorContextAction>,
result: DropResult,
moduleID: number,
) => {
const { source, destination } = result;
// dropped outside the list
if (
source?.droppableId !== "LESSON_EDITOR" ||
destination?.droppableId !== "LESSON_EDITOR"
) {
return;
}
dispatch({
type: "reorder-lessons",
value: {
moduleID,
oldIndex: source.index,
newIndex: destination.index,
},
});
};

const SideBarModuleOverview = (): React.ReactElement => {
const context = useContext(EditorContext);
const { isOpen, onOpen, onClose } = useDisclosure();
Expand All @@ -25,8 +53,6 @@ const SideBarModuleOverview = (): React.ReactElement => {
const { lessons, course, focusedLesson } = state;
const module = course.modules[moduleID];

const orderedLessons = module.lessons.map((id) => lessons[id]);

const setFocus = (index: number) =>
dispatch({ type: "set-focus", value: module.lessons[index] });

Expand Down Expand Up @@ -57,15 +83,29 @@ const SideBarModuleOverview = (): React.ReactElement => {

return (
<VStack>
{focusedLesson &&
orderedLessons.map((lesson, index) => (
<LessonItem
text={lesson.title}
isFocused={state.lessons[focusedLesson] === lesson}
key={lesson.id}
setFocus={() => setFocus(index)}
/>
))}
{focusedLesson && (
<DragDropContext
onDragEnd={(result) => onDragEnd(dispatch, result, moduleID)}
>
<Droppable droppableId="LESSON_EDITOR">
{(provided) => (
<VStack w="100%" ref={provided.innerRef}>
{module.lessons.map((id, index) => (
<LessonItem
id={id}
text={lessons[id].title}
isFocused={state.lessons[focusedLesson] === lessons[id]}
key={id}
setFocus={() => setFocus(index)}
index={index}
/>
))}
{provided.placeholder}
</VStack>
)}
</Droppable>
</DragDropContext>
)}
<Button
onClick={onOpen}
color="brand.royal"
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/reducers/ModuleEditorContextReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,53 @@ const replaceLessonID = (
return newState;
};

const reorderLessons = (
state: EditorStateType,
moduleID: number,
oldIndex: number,
newIndex: number,
): EditorStateType => {
// focusedLesson should exist in the same module as desired lesson
const lessonID = state.focusedLesson;
if (!lessonID) {
return state;
}

const { id: courseID } = state.course; // id refers to the course document id

const oldModule = state.course.modules[moduleID];

console.assert(oldIndex >= 0, "Old lesson index must be positive");
console.assert(
oldIndex < oldModule.lessons.length,
"Old lesson index exceeds lesson length",
);
console.assert(newIndex >= 0, "New lesson index must be positive");
console.assert(
newIndex < oldModule.lessons.length,
"New lesson index exceeds lesson length",
);

const newLessons = [...state.course.modules[moduleID].lessons];
const [draggedLesson] = newLessons.splice(oldIndex, 1);
newLessons.splice(newIndex, 0, draggedLesson);
const newModules = [...state.course.modules];
newModules[moduleID] = { ...newModules[moduleID], lessons: newLessons };
const newState = {
...state,
course: {
...state.course,
modules: newModules,
},
};
newState.hasChanged = updateChangeStatus(
state.hasChanged,
courseID,
"COURSE-UPDATE",
);
return newState;
};

const deleteLesson = (state: EditorStateType, id: string) => {
if (Object.keys(state.lessons).includes(id)) return state;

Expand Down Expand Up @@ -252,6 +299,13 @@ export default function EditorContextReducer(
return deleteLesson(state, action.value);
case "update-lesson-id":
return replaceLessonID(state, action.value.oldID, action.value.newID);
case "reorder-lessons":
return reorderLessons(
state,
action.value.moduleID,
action.value.oldIndex,
action.value.newIndex,
);
case "create-block":
return createLessonContentBlock(
state,
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/types/ModuleEditorTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ export interface ModuleEditorParams {
moduleIndex: string;
}

export type EditorChangeStatus = "CREATE" | "UPDATE" | "DELETE";
export type EditorChangeStatus =
| "CREATE"
| "UPDATE"
| "DELETE"
| "COURSE-UPDATE";

export interface EditorChangeStatuses {
[doc_id: string]: EditorChangeStatus;
Expand Down Expand Up @@ -113,6 +117,14 @@ export type EditorContextAction =
type: "delete-lesson";
value: string;
}
| {
type: "reorder-lessons";
value: {
moduleID: number;
oldIndex: number;
newIndex: number;
};
}
| {
type: "update-lesson-id";
value: { oldID: string; newID: string };
Expand Down

0 comments on commit 7f5595f

Please sign in to comment.