Introduction
If you haven't followed Part 1, please refer to React Trello - Part 1. If you haven't followed Part 2, please refer to React Trello - Part 2. If you haven't followed Part 3, please refer to React Trello - Part 3.
Up until now, we have created a Trello board that allows for column swapping, adding new columns, and tasks. Now, we will focus on implementing task swapping among themselves.
Drag Column
To begin, we will add a type to our DragItem:
export type CardDragItem = {
index: number
id: string
columnId: string
text: string
type: "CARD"
}
export type ColumnDragItem = {
index: number
id: string
text: string
type: "COLUMN"
}
export type DragItem = CardDragItem | ColumnDragItem
Now, our type can be either CardDragItem or ColumnDragItem. We've defined this type, but it's important to review our card model and add the following properties:
- id: to differentiate cards in the same way we did for columns
- isPreview: to know if the card is currently in preview mode or not, like columns
- columnId: to know which column the card is in.
interface CardProps {
text: string
index: number
id: string
columnId: string
isPreview?: boolean
}
Don't panic, you will encounter several errors! Before fixing them, let's implement our reducer that will be located in the Card component. We will add the MOVE_TASK action:
{
type: "MOVE_TASK"
payload: {
dragIndex: number
hoverIndex: number
sourceColumn: string
targetColumn: string
}
}
We can notice that we have two additional attributes for the MOVE_TASK action compared to the action for columns:
- sourceColumn: the ID of the source column
- targetColumn: the ID of the target column.
We now need to declare this action in our reducer:
case "MOVE_TASK": {
const {
dragIndex,
hoverIndex,
sourceColumn,
targetColumn
} =
action.payload;
return {};
}
We have destructured our payload to provide the following elements:
- sourceColumnIndex: which will allow us to retrieve the index of the item in the source column using sourceColumn
- targetColumnIndex: which will allow us to retrieve the index of the item in the target column using targetColumn
- updatedSourceColumn: the updated source column using the dragIndex index
- stateWithUpdatedSourceColumn: the updated state with the updated column.
case "MOVE_TASK": {
const {
dragIndex,
hoverIndex,
sourceColumn,
targetColumn
} =
action.payload;
const sourceColumnIndex = findItemIndexById(state.lists, sourceColumn);
const targetColumnIndex = findItemIndexById(state.lists, targetColumn);
const sourceList = state.lists[sourceColumnIndex];
const task = sourceList.tasks[targetColumnIndex];
const updatedSourceList = {
...sourceList,
tasks: removeItemAtIndex(sourceList.tasks, dragIndex),
};
const stateWithUpdatedSourceList = {
...state,
lists: overrideItemAtIndex(
state.lists,
updatedSourceList,
sourceColumnIndex
),
};
return {};
}
Here are some explanations:
- sourceColumnIndex and targetColumnIndex contain the index of the task in the column
- sourceList represents the column (= list of tasks) obtained using sourceColumnIndex
- task is obtained using targetColumnIndex and represents the task
- updatedSourceList updates the source list by removing the moved task
- stateWithUpdatedSourceList updates the state
We now need to do the same thing for the target, then return the updated state with the actions performed:
const targetList = stateWithUpdatedSourceList.lists[targetListIndex];
const updatedTargetList = {
…
targetList,
tasks: insertItemAtIndex(targetList.tasks, task, hoverIndex),
};
return {
…
stateWithUpdatedSourceList,
lists: overrideItemAtIndex(
stateWithUpdatedSourceList.lists,
updatedTargetList,
targetListIndex
),
};
Our reducer is ready! We will simply refactor our useItemDrop dispatcher so that it can accept cards as well as columns. As you know, this dispatcher can accept the DragItem type which can be a column or a card. To differentiate the types, we are fortunate to have a property named type. This is likely to generate a lot of hard-coded types (don't worry, we will improve the code in future chapters). So, I suggest creating an enumeration. Let's create an enums folder and add the type.ts enumeration to it:
export enum TypeEnum {
Column = "COLUMN",
Card = "CARD",
}
I will make the necessary updates to replace all hard-coded values with the enumeration. Then, we will update our component to accept both cards and columns. First, we can separate the code for columns as follows:
function dispatchForColumn(
item: DragItem,
hoverIndex: number,
dispatch: Function
) {
const dragIndex = item.index;
if (dragIndex === hoverIndex) {
return;
}
dispatch({
type: "MOVE_LIST",
payload: {
dragIndex,
hoverIndex
}
});
}
export const useItemDrop = (index: number) => {
const {
dispatch
} = useAppState();
const [, drop] = useDrop({
accept: [TypeEnum.Column, TypeEnum.Card],
hover(item: DragItem) {
const hoverIndex = index;
console.log(typeof item);
if (item.type === TypeEnum.Column) {
dispatchForColumn(item, hoverIndex, dispatch);
} else {}
item.index = hoverIndex;
},
});
return {
drop
};
};
For cards, we need to follow a different behavior:
- We need to check that the ID of the card we hover over is not the element we are moving
- We need to retrieve the indexes of the source column and the target column
- We need to use the dispatcher to call the MOVE_TASK action
- We need to add the column ID.
function dispatchForColumn(
item: DragItem,
hoverIndex: number,
dispatch: Function
) {
const dragIndex = item.index;
if (dragIndex === hoverIndex) {
return;
}
dispatch({ type: "MOVE_LIST", payload: { dragIndex, hoverIndex } });
}
function dispatchForCard(
item: CardDragItem,
hoverIndex: number,
targetColumn: string,
dispatch: Function
) {
const dragIndex = item.index;
const sourceColumn = item.columnId;
dispatch({
type: "MOVE_TASK",
payload: { dragIndex, hoverIndex, sourceColumn, targetColumn },
});
item.index = hoverIndex;
item.columnId = targetColumn;
}
export const useItemDrop = (index: number, columnId: string) => {
const { dispatch } = useAppState();
const [, drop] = useDrop({
accept: [TypeEnum.Column, TypeEnum.Card],
hover(item: DragItem) {
const hoverIndex = index.toString() === columnId ? 0 : index;
if (item.type === TypeEnum.Column) {
dispatchForColumn(item, hoverIndex, dispatch);
} else {
dispatchForCard(item, hoverIndex, columnId, dispatch);
}
item.index = hoverIndex;
},
});
return { drop };
};
Nothing too complicated if you have followed the previous steps. The main difference with the column is that we take the index of the element being moved (dragIndex) and the ID of the source column (sourceColumn), just like the hover information. So we have set up the drop functionality for cards. The good news is that we don't need to change anything for our drag functionality. We now need to import our two dispatchers into our Card.tsx:
interface CardProps {
text: string;
index: number;
id: string;
columnId: string;
isPreview?: boolean;
}
export const Card = ({ text, id, index, columnId, isPreview }: CardProps) => {
const { state } = useAppState();
const ref = useRef<HTMLDivElement>(null);
const { drag } = useItemDrag({
type: TypeEnum.Card,
id,
index,
text,
columnId,
});
const { drop } = useItemDrop(index, columnId);
drag(drop(ref));
return (
<CardContainer
isHidden={isHidden(isPreview, state.draggedItem, TypeEnum.Card, id)}
isPreview={isPreview}
ref={ref}
>
{text}
</CardContainer>
);
};
This looks like the setup for columns, but with some modifications to adapt to our component. Now we can adapt our CardContainer which must implement DragPreviewContainer:
export const CardContainer = styled(DragPreviewContainer)`
background-color: #fff;
cursor: pointer;
margin-bottom: 0.5rem;
padding: 0.5rem 1rem;
max-width: 300px;
border-radius: 3px;
box-shadow: #091e4240 0px 1px 0px 0px;
`;
Before we can test our code, we need to fix an error. This error concerns the Card component defined in the Column component. We need to pass the missing elements to our type:
export const Column = ({ text, index, id, isPreview }: ColumnProps) => {
const { state, dispatch } = useAppState();
const ref = useRef<HTMLDivElement>(null);
const { drop } = useItemDrop(index, id);
const { drag } = useItemDrag({ type: TypeEnum.Column, id, index, text });
drag(drop(ref));
return (
<ColumnContainer
isPreview={isPreview}
ref={ref}
isHidden={isHidden(isPreview, state.draggedItem, TypeEnum.Column, id)}
>
<ColumnTitle>{text}</ColumnTitle>
{state.lists[index].tasks.map((task, i) => (
<Card
id={task.id}
columnId={id}
text={task.text}
key={task.id}
index={i}
/>
))}
<AddNewItem
toggleButtonText="+ Add another card"
onAdd={(text) =>
dispatch({ type: "ADD_TASK", payload: { text, listId: id } })
}
dark
/>
</ColumnContainer>
);
};
We also need to update our CustomDragLayer to accept cards:
const CustomDragLayer: React.FC = () => {
const { isDragging, item, currentOffset } = useDragLayer((monitor) => ({
item: monitor.getItem(),
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
}));
if (!isDragging) {
return null;
}
return (
<CustomDragLayerContainer>
<div style={getItemStyles(currentOffset)}>
{item.type === "COLUMN" ? (
<Column
id={item.id}
text={item.text}
index={item.index}
isPreview={true}
/>
) : (
<Card
columnId={item.columnId}
isPreview={true}
index={0}
id={item.id}
text={item.text}
/>
)}
</div>
</CustomDragLayerContainer>
);
};
export default CustomDragLayer;
There is a problem: when we try to move columns, an error occurs. This is because our hover does not detect the correct element! To fix this, we will remove our useItemDrop dispatcher and let the component handle it:
export const Column = ({ text, index, id, isPreview }: ColumnProps) => {
const { state, dispatch } = useAppState();
const ref = useRef<HTMLDivElement>(null);
const [, drop] = useDrop({
accept: [TypeEnum.Column, TypeEnum.Card],
hover(item: DragItem) {
if (item.type === TypeEnum.Column) {
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) {
return;
}
dispatch({ type: "MOVE_LIST", payload: { dragIndex, hoverIndex } });
item.index = hoverIndex;
} else {
const dragIndex = item.index;
const hoverIndex = 0;
const sourceColumn = item.columnId;
const targetColumn = id;
if (sourceColumn === targetColumn) {
return;
}
dispatch({
type: "MOVE_TASK",
payload: { dragIndex, hoverIndex, sourceColumn, targetColumn },
});
item.index = hoverIndex;
item.columnId = targetColumn;
}
},
});
const { drag } = useItemDrag({ type: TypeEnum.Column, id, index, text });
drag(drop(ref));
return (
<ColumnContainer
isPreview={isPreview}
ref={ref}
isHidden={isHidden(isPreview, state.draggedItem, TypeEnum.Column, id)}
>
<ColumnTitle>{text}</ColumnTitle>
{state.lists[index].tasks.map((task, i) => (
<Card
id={task.id}
columnId={id}
text={task.text}
key={task.id}
index={i}
/>
))}
<AddNewItem
toggleButtonText="+ Add another card"
onAdd={(text) =>
dispatch({ type: "ADD_TASK", payload: { text, listId: id } })
}
dark
/>
</ColumnContainer>
);
};
You should have the following result:
Conclusion
To summarize, in any training, it is important to present the most important concepts. However, it is also important to consider possible improvements at the code level, such as:
- Properly divide the code by grouping scattered types into a "models" folder.
- Use enumerations for texts. If you have to make choices in texts, it is recommended to group them into enumerations.
- Create reusable components to save time in the future.
- We created our dispatchers, but it is possible to use Redux instead (we will see this later).