Skip to content

Latest commit

 

History

History
490 lines (397 loc) · 15.6 KB

File metadata and controls

490 lines (397 loc) · 15.6 KB

In this lesson, we will add drag and drop capability to our task lists.

Let's open DragDropList.tsx in VS Code.

We will have to import DragDropContext to create an area where a user will be able to drag an element or drop an element. Then, we will also have to import OnDragEndResponder, which will be triggered when a drag and drop action is ended.

import { DragDropContext, OnDragEndResponder } from "react-beautiful-dnd";

We will wrap the Container within DragDropContext.

return (
  <DragDropContext onDragEnd={onDragEnd}>
    <Container>
      {props.data.columnOrder.map((colID) => {
        const column = props.data.columns[colID];
        const tasks = column.taskIDs.map((taskID) => props.data.tasks[taskID]);
        return <Column key={column.id} column={column} tasks={tasks} />;
      })}
    </Container>
  </DragDropContext>
);

We will add a function onDragEnd to pass as prop to DragDropContext component.

const onDragEnd: OnDragEndResponder = (result) => {};

Next, we have to modify the Column component to make it accept a dropped item. Switch to Column.tsx file.

To support the drop feature, we will have to import Droppable component from react-beautiful-dnd.

import { Droppable } from "react-beautiful-dnd";

Droppable component takes a droppableId as a prop, which should be unique. It is with this droppableId, different drop locations are identified, when a drag and drop action ends.

Let's wrap the TaskList component within the Droppable component and pass props.column.id- which will have values like pending, in_progress, and done as the droppableId prop.

const Column: React.FC<Props> = (props) => {
  return (
    <Container>
      <Title>{props.column.title}</Title>
      <Droppable droppableId={props.column.id}>
        <TaskList>
          {props.tasks.map((task) => (
            <Task key={task.id} task={task} />
          ))}
        </TaskList>
      </Droppable>
    </Container>
  );
};

Now, it will display an error:

Type 'Element' is not assignable to type '(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ReactElement<HTMLElement, string | JSXElementConstructor>'.

This is because, Droppable component expects a function as its child, and we are providing an Element (TaskList component).

Let's modify the Column component to adhere to the requirement. The function should have the signature (provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ReactElement<HTMLElement, string | JSXElementConstructor<any>>.

The first argument in the function, provided will have two attributes - innerRef and droppableProps, that should be passed along as props to the target element which is to be made droppable. We will use the spread operator to pass along the provided.droppableProps to our TaskList component. We need to add provided.placeholder to support as a placeholder for enabling drop capability.

const Column: React.FC<Props> = (props) => {
  return (
    <Container>
      <Title>{props.column.title}</Title>
      <Droppable droppableId={props.column.id}>
        {(provided) => (
          <TaskList ref={provided.innerRef} {...provided.droppableProps}>
            {props.tasks.map((task) => (
              <Task key={task.id} task={task} />
            ))}
            {provided.placeholder}
          </TaskList>
        )}
      </Droppable>
    </Container>
  );
};

Now, it displays another error:

Property 'ref' does not exist on type 'IntrinsicAttributes & { children?: ReactNode; }

React provides means to directly access a DOM element and interact with it. This is done by attaching a prop called ref to a component. Usually, to add this capability, we use useRef hook.

But since the provided.innerRef comes from a parent component, we will have to use forwardRef on the TaskList component.

Let's modify our TaskList component.

Import forwardRef

import React, { forwardRef } from "react";

Use forwardRef to provide a ref passed as prop from any parent component. We will attach the passed ref on to the div tag. We will also spread the passed along props on the div tag.

const TaskList = forwardRef<HTMLDivElement | null, React.PropsWithChildren>(
  (props: React.PropsWithChildren, ref) => {
    return (
      <div ref={ref} className="grow min-h-100 dropArea" {...props}>
        {" "}
        {props.children}
      </div>
    );
  }
);

Next, we have to make each task item draggable. Switch to Task.tsx.

To make an element draggable, we will have to wrap it within Draggable component from react-beautiful-dnd package.

Let's import it first.

import { Draggable } from "react-beautiful-dnd";

Now, let's wrap our Container component within Draggable. Similar to Droppable, Draggable also expects a function as its child. We will also have to pass, provided.innerRef to the Task component.

const Container = (
  props: React.PropsWithChildren<{
    task: TaskDetails;
  }>
) => {
  return (
    <Draggable>
      {(provided) => <Task task={props.task} ref={provided.innerRef} />}
    </Draggable>
  );
};

Now, it shows few errors:

Type '{ children: (provided: DraggableProvided) => Element; }' is missing the following properties from type 'Readonly': draggableId, index

Draggable expects two props, draggableId, which is used to uniquely identify the item which can be dragged. Then an index, that will decide the ordering of an item in a list. Let's provide both of these props.

Let's modify the signature of Container component to accept a number.

const Container = (
  props: React.PropsWithChildren<{
    task: TaskDetails;
    index: number;
  }>
) => {
  // ...
};

Now we can pass it as a prop to the Draggable component. We will also need to pass along provided.draggableProps and provided.dragHandleProps to make an element draggable. We will use the spread operator to pass these as props to the Task component.

const Container = (
  props: React.PropsWithChildren<{
    task: TaskDetails;
    index: number;
  }>
) => {
  return (
    <Draggable index={props.index} draggableId={`${props.task.id}`}>
      {(provided) => (
        <Task
          task={props.task}
          ref={provided.innerRef}
          {...provided.draggableProps}
          {...provided.dragHandleProps}
        />
      )}
    </Draggable>
  );
};

We can set draggableId as the task id itself. react-beautiful-dnd expects the draggableId to be string, so we have to convert it from number to string type using the string interpolation.

Now, the only error remaining is:

Property 'ref' does not exist on type 'IntrinsicAttributes & { task: TaskDetails; } & { children?: ReactNode; }'

We can fix this by wrapping the Task component in a forwardRef.

Let's import forwardRef first.

import React, { forwardRef } from "react";

Now wrap the Task in forwardRef function. Make sure to place the parenthesis correctly. Also, we will have to set the ref on the div tag, as well as pass on the props to it.

const Task = forwardRef<
  HTMLDivElement,
  React.PropsWithChildren<{ task: TaskDetails }>
>((props, ref) => {
  const { task } = props;
  // Attach the `ref` and spread the `props`
  return (
    <div ref={ref} {...props} className="m-2 flex">
      ...
    </div>
  );
});

Now, save the file. We still have another error in Column.tsx. Let's fix that. Open Column.tsx file.

We are not passing an index as a prop to Task component when rendering. This is an easy to fix issue.

The map construct provides an index as second argument while iterating. We can use it to pass as prop to the Task component.

const Column: React.FC<Props> = (props) => {
  return (
    <Container>
      <Title>{props.column.title}</Title>
      <Droppable droppableId={props.column.id}>
        {(provided) => (
          <TaskList ref={provided.innerRef} {...provided.droppableProps}>
            {props.tasks.map((task, idx) => (
              <Task key={task.id} task={task} index={idx} />
            ))}
            {provided.placeholder}
          </TaskList>
        )}
      </Droppable>
    </Container>
  );
};

Save the file. Now, we should be able to drag and drop the task items. One issue is, we cannot drag a task and drop it into another list.

Let's fix that.

Open DragDropList.tsx file.

When a drag and drop action is ended, the onDragEnd will be invoked with a result. The result will have data like destination, source, draggableId. Let's use this to change the state of a task.

We will first pull out these data from result

const onDragEnd: OnDragEndResponder = (result) => {
  const { destination, source, draggableId } = result;
};

Next, we will do some sanity checks like if the task is being dropped to some area that is not droppable, we will do nothing. If the task is being taken out from a list, then is being dropped at the same list and position, then also we will do nothing.

const onDragEnd: OnDragEndResponder = (result) => {
  const { destination, source, draggableId } = result;
  if (!destination) {
    return;
  }
  if (
    destination.droppableId === source.droppableId &&
    destination.index === source.index
  ) {
    return;
  }
};

Now, we have some valid movement of tasks. We will create a new state, then invoke the reorderTasks method to update the orderings.

Let's cast the source.droppableId as a value of AvailableColumns. We will do the same for destination.droppableId as well. We do this so that TypeScript can help us with intelliSense.

const startKey = source.droppableId as AvailableColumns;
const finishKey = destination.droppableId as AvailableColumns;

Before going further, we will have to import reorderTasks from action.ts as well as useTasksDispatch from src/context/task/context.

import { useTasksDispatch } from "../../context/task/context";
import { reorderTasks } from "../../context/task/actions";

Let's get the value out of task context.

const DragDropList: React.FC<{ data: ProjectData }> = (props) => {
  const taskDispatch = useTasksDispatch();
  const { projectID } = useParams();
  // ...
};

Next, we will create the new ordering or new state once a task is dropped. We will use the spread operator to preserve any previous state, which we are not currently interested.

We will

  • Create a new array of task ids.
  • Then remove the dragged item using source.index.
  • Then we will insert the id at destination.index.
  • Then we will update the columns key in the state with this newly computed column ordering.
  • Finally, invoke reorderTasks with the new state.

We will also have to import AvailableColumns type.

import { AvailableColumns, ProjectData } from "../../context/task/types";

onDragEnd will look like:

const onDragEnd: OnDragEndResponder = (result) => {
  const { destination, source, draggableId } = result;
  if (!destination) {
    return;
  }
  if (
    destination.droppableId === source.droppableId &&
    destination.index === source.index
  ) {
    return;
  }
  // Get source list
  const startKey = source.droppableId as AvailableColumns;
  // Get destination list
  const finishKey = destination.droppableId as AvailableColumns;

  // Get source list to modify
  const start = props.data.columns[startKey];
  // Get destination list to modify
  const finish = props.data.columns[finishKey];

  const newTaskIDs = Array.from(start.taskIDs);
  // Remove the dragged item from source list
  newTaskIDs.splice(source.index, 1);
  // Insert the item to destination list
  newTaskIDs.splice(destination.index, 0, draggableId);
  const newColumn = {
    ...start,
    taskIDs: newTaskIDs,
  };
  const newState = {
    ...props.data,
    columns: {
      ...props.data.columns,
      [newColumn.id]: newColumn,
    },
  };
  reorderTasks(taskDispatch, newState);
  return;
};

Save the file. Now, if we check, we are still unable to drop the task to a different list. But dragging and dropping in the same list is working fine.

We have to handle the case when finish is different from start. We will move the above logic into an if block and execute only if start and finish are the same.

const onDragEnd: OnDragEndResponder = (result) => {
  const { destination, source, draggableId } = result;
  if (!destination) {
    return;
  }
  if (
    destination.droppableId === source.droppableId &&
    destination.index === source.index
  ) {
    return;
  }
  const startKey = source.droppableId as AvailableColumns;
  const finishKey = destination.droppableId as AvailableColumns;

  const start = props.data.columns[startKey];
  const finish = props.data.columns[finishKey];

  if (start === finish) {
    const newTaskIDs = Array.from(start.taskIDs);
    newTaskIDs.splice(source.index, 1);
    newTaskIDs.splice(destination.index, 0, draggableId);
    const newColumn = {
      ...start,
      taskIDs: newTaskIDs,
    };
    const newState = {
      ...props.data,
      columns: {
        ...props.data.columns,
        [newColumn.id]: newColumn,
      },
    };
    reorderTasks(taskDispatch, newState);
    return;
  }
  // else the item is being dropped to a different list
};

If the lists are different, we will have to create new entries for those columns in the new state.

const DragDropList = (props: {
  data: ProjectData;
}) => {
  const taskDispatch = useTasksDispatch();
  const onDragEnd: OnDragEndResponder = (result) => {
    const { destination, source, draggableId } = result;
    if (!destination) {
      return;
    }
    if (
      destination.droppableId === source.droppableId &&
      destination.index === source.index
    ) {
      return;
    }
    const startKey = source.droppableId as AvailableColumns;
    const finishKey = destination.droppableId as AvailableColumns;

    const start = props.data.columns[startKey];
    const finish = props.data.columns[finishKey];

    if (start === finish) {
      const newTaskIDs = Array.from(start.taskIDs);
      newTaskIDs.splice(source.index, 1);
      newTaskIDs.splice(destination.index, 0, draggableId);
      const newColumn = {
        ...start,
        taskIDs: newTaskIDs,
      };
      const newState = {
        ...props.data,
        columns: {
          ...props.data.columns,
          [newColumn.id]: newColumn,
        },
      };
      reorderTasks(taskDispatch, newState);
      return;
    }
    // start and finish list are different

    const startTaskIDs = Array.from(start.taskIDs);
    // Remove the item from `startTaskIDs`
    const updatedItems = startTaskIDs.splice(source.index, 1);

    const newStart = {
      ...start,
      taskIDs: startTaskIDs,
    };

    const finishTaskIDs = Array.from(finish.taskIDs);

    // Insert the item to destination list.
    finishTaskIDs.splice(destination.index, 0, draggableId);
    const newFinish = {
      ...finish,
      taskIDs: finishTaskIDs,
    };

    // Create new state with newStart and newFinish
    const newState = {
      ...props.data,
      columns: {
        ...props.data.columns,
        [newStart.id]: newStart,
        [newFinish.id]: newFinish,
      },
    };
    reorderTasks(taskDispatch, newState);
  };

Save the file. Now, we should be able to drag and drop items between different lists. When we drag and drop the tasks or change its order, new state is computed and is passed to the task context by invoking the reorderTasks action. This will trigger the dispatch for TaskListAvailableAction.REORDER_TASKS with updated payload. The reducer will then update the state with the latest data and renders the lists with updated state.

See you in the next lesson.