logo
    • Accueil
    • Catégories
    • A propos
  • fr-languageFrançais
Interface utilisateurPar Pierre Colart

React - Typescript par la pratique - Trello - Column drag and drop

Introduction

Si vous n'avez pas suivi les parties précédentes, voici les liens :

Dans les parties précédentes, nous avons ajouté le state à notre application. Nous avons également ajouté un reducer qui nous permet, à travers des actions, de créer de nouveaux objets plutôt que d'appliquer des mutations directement sur l'ancien objet. Grâce à ce state, nous avons été capables d'ajouter dynamiquement des colonnes ainsi que des tâches.

Dans cette partie, nous allons ajouter la possibilité de faire du drag and drop sur les colonnes et les tâches. Nous devrons créer l'animation nécessaire pour rendre cette fonctionnalité fluide, ajouter les actions, et intégrer react-dnd.

Drag colonne

Pour permettre la mise à jour de la position des colonnes dans le state, nous devons ajouter une nouvelle action. Pour ce faire, nous devons nous rendre dans le fichier app-state-action.tsx :

   | {
      type: "MOVE_LIST";
      payload: {
        dragIndex: number;
        hoverIndex: number;
      };
    }

Dans cette action, nous avons plusieurs éléments :

  • dragIndex : Nous utilisons la position d'origine de la colonne que nous passons à notre variable dragIndex.
  • hoverIndex : Lorsque nous survolons une autre colonne, nous prenons sa position et nous l'assignons à hoverIndex.
  • Avant de continuer et d'ajouter ces actions à notre reducer, nous devons créer trois fonctions dans notre utilitaire pour les tableaux :
  1. moveItem : qui nous permettra de changer l'indice d'un élément dans le tableau.
  2. removeItemAtIndex : qui nous permettra de supprimer un élément à un indice donné dans le tableau.
  3. insertItemAtIndex : qui nous permettra d'ajouter un élément dans le tableau à un indice donné.
 export const moveItem = <T>(array: T[], from: number, to: number) => {
  const item = array[from];
  return insertItemAtIndex(removeItemAtIndex(array, from), item, to);
};

export function removeItemAtIndex<T>(array: T[], index: number) {
  return [...array.slice(0, index), ...array.slice(index + 1)];
}

export function insertItemAtIndex<T>(array: T[], item: T, index: number) {
  return [...array.slice(0, index), item, ...array.slice(index)];
}

L'ensemble des actions est typé de manière générique, ce qui nous donne de la flexibilité. Nous déplaçons les éléments sans modifier le tableau d'origine en utilisant les actions removeItemAtIndex pour supprimer des indices et insertItemAtIndex pour ajouter des éléments dans les tableaux.

Maintenant, nous pouvons ajouter cette action à AppStateReducer :

 case "MOVE_LIST": {
    const {
        dragIndex,
        hoverIndex
    } = action.payload;
    return {
        ...state,
        lists: moveItem(state.lists, dragIndex, hoverIndex),
    };
}

L'étape suivante consiste à ajouter la bibliothèque react-dnd qui nous permettra de faire notre glisser-déposer. Elle comprend de nombreux adaptateurs qui prennent en charge de nombreuses APIs, telles que HTML5.

 npm i react-dnd react-dnd-html5-backend

Une fois l'installation terminée, nous pouvons ajouter cette bibliothèque directement à partir de notre CDN :

 import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./app";
import reportWebVitals from "./reportWebVitals";
import { DndProvider } from "react-dnd";
import { HTML5Backend as Backend } from "react-dnd-html5-backend";
import { AppStateProvider } from "./states/app-state-provider";

ReactDOM.render(
  <DndProvider backend={Backend}>
    <AppStateProvider>
      <App />
    </AppStateProvider>
  </DndProvider>,
  document.getElementById("root")
);

Nous avons ajouté un contexte pour le glisser-déposer à notre application. Cela nous permettra d'utiliser les hooks useDrag et useDrop dans tous nos composants. Maintenant, nous devons créer notre type ColumnDragItem, qui comprend les champs suivants :

  • index : l'indice de la colonne
  • id : l'ID généré précédemment
  • text : le texte généré pour la colonne
  • type : ici, il sera codé en dur pour dire "COLUMN"

Ce type peut être ajouté dans un dossier nommé models (comme la plupart des types précédemment créés si vous pensez les utiliser plusieurs fois) :

 export type ColumnDragItem = {
  index: number;
  id: string;
  text: string;
  type: "COLUMN";
};
export type DragItem = ColumnDragItem;

Une des limitations des hooks de react-dnd est que nous n'avons accès qu'à l'élément qui est en cours d'utilisation. Le problème est le suivant : lors du glisser-déposer, nous avons une prévisualisation du glissement que nous avons commencé à bouger, mais si nous ne cachons pas le composant d'origine (l'état de départ), nous aurons l'impression de créer une duplication plutôt qu'un véritable mouvement.

Pour résoudre ce problème, nous devons cacher l'élément d'origine. Nous allons donc créer un nouveau type dans notre projet : AppState.

 export interface AppState {
  lists: Column[];
  draggedItem: DragItem | undefined;
}

Mettons à jour appData :

 export const appData: AppState = {
  draggedItem: undefined,
  lists: [
    {
      id: "0",
      text: "To Do",
      tasks: [{ id: "c0", text: "Generate app scaffold" }],
    },
    {
      id: "1",
      text: "In Progress",
      tasks: [{ id: "c2", text: "Learn Typescript" }],
    },
    {
      id: "2",
      text: "Done",
      tasks: [{ id: "c3", text: "Begin to use static typing" }],
    },
  ],
};

Il nous reste deux choses à faire :

  1. Ajouter l'action SET_DRAGGED_ITEM : qui aura un payload comprenant notre modèle précédemment défini.
  2. Implémenter cette action dans notre reducer.

Dans app-state-action :

 import { DragItem } from "../models/drag-item";

export type Action =
  | {
      type: "ADD_LIST";
      payload: string;
    }
  | {
      type: "ADD_TASK";
      payload: { text: string; listId: string };
    }
  | {
      type: "MOVE_LIST";
      payload: {
        dragIndex: number;
        hoverIndex: number;
      };
    }
  | {
      type: "SET_DRAGGED_ITEM";
      payload: DragItem | undefined;
    };

Implémentation dans notre reducer app-state-reducer:

 case "SET_DRAGGED_ITEM": {
    return {
        ...state,
        draggedItem: action.payload
    };
}

Nous pouvons créer un hook qui renvoie la méthode drag qui accepte la référence d'un élément qui peut être déplacé. Ce hook va déclencher l'action SET_DRAGGED_ITEM pour sauvegarder l'élément dans le state. Lorsque nous terminons l'action, cela déclenchera à nouveau l'action, mais avec undefined comme payload.

Créons un dossier nommé dispatchers et ajoutons le fichier use-item-drag.ts :

 import { useDrag } from "react-dnd";
import { DragItem } from "../models/drag-item";
import { useAppState } from "../states/app-state-context";

export const useItemDrag = (item: DragItem) => {
  const { dispatch } = useAppState();
  const [, drag] = useDrag({
    item,
    begin: () =>
      dispatch({
        type: "SET_DRAGGED_ITEM",
        payload: item,
      }),
    end: () => dispatch({ type: "SET_DRAGGED_ITEM", payload: undefined }),
  });
  return { drag };
};

Nous allons ajouter l'option de drag à notre composant column :

 import React, { useRef } from "react";
import { ColumnContainer } from "../styled-components/container";
import { ColumnTitle } from "../styled-components/title";
import { AddNewItem } from "./add-new-item";
import { useAppState } from "../states/app-state-context";
import { Card } from "../components/card";
import { useItemDrag } from "../dispatchers/use-item-drag";

interface ColumnProps {
  text: string;
  index: number;
  id: string;
}

export const Column = ({ text, index, id }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  const ref = useRef<HTMLDivElement>(null);
  const { drag } = useItemDrag({ type: "COLUMN", id, index, text });
  drag(ref);
  return (
    <ColumnContainer ref={ref}>
      <ColumnTitle>{text}</ColumnTitle>
      {state.lists[index].tasks.map((task, i) => (
        <Card 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>
  );
};

Nous pouvons vérifier que le drag fonctionne :

Ici, nous avons créé un glisser-déposer qui crée une image fantôme de la colonne en cours de mouvement. De plus, nous constatons que l'image originale reste en place et que nous ne pouvons pas déposer notre image.

Drop colonne

Comme mentionné précédemment, nous pouvons faire glisser une colonne, mais pas encore la déposer. Pour trouver l'endroit où nous allons déposer la colonne, nous allons utiliser les autres colonnes comme cibles. Donc, lorsque nous ne sommes pas sur une colonne, nous allons déclencher l'action MOVE_LIST pour changer la position de la colonne.

Dans notre fichier Column, nous pouvons utiliser useDrop de react-dnd. Créons un nouveau dispatcher appelé use-item-drop.ts :

 import { useDrop } from "react-dnd";
import { DragItem } from "…/models/drag-item";
import { useAppState } from "…/states/app-state-context";

export const useItemDrop = (index: number) => {
  const { dispatch } = useAppState();

  const [, drop] = useDrop({
    accept: "COLUMN",
    hover(item: DragItem) {
      const dragIndex = item.index;
      const hoverIndex = index;
      if (dragIndex === hoverIndex) {
        return;
      }
      dispatch({ type: "MOVE_LIST", payload: { dragIndex, hoverIndex } });
      item.index = hoverIndex;
    },
  });
  return { drop };
};

Nous allons maintenant mettre à jour notre item column.ts :

 export const Column = ({ text, index, id }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  const ref = useRef<HTMLDivElement>(null);
  const { drag } = useItemDrag({ type: "COLUMN", id, index, text });
  const { drop } = useItemDrop(index);
  drag(drop(ref));

Hide colonne

Nous allons maintenant supprimer la colonne qui est sélectionnée afin de supprimer cet effet de duplication. Dans notre fichier Container.tsx, nous allons ajouter DragPreviewContainer, qui pourra être réutilisé à plusieurs reprises, mais surtout styliser notre conteneur :

 interface DragPreviewContainerProps {
  isHidden?: boolean;
}

export const DragPreviewContainer = styled.div<DragPreviewContainerProps>`
  opacity: ${(props) => (props.isHidden ? 0.3 : 1)};
`;

Nous devons maintenant calculer comment masquer les colonnes. Pour cela, il convient d'ajouter un nouvel utilitaire appelé is-hidden.ts :

 import { DragItem } from "../models/drag-item";

export const isHidden = (
  draggedItem: DragItem | undefined,
  itemType: string,
  id: string
): boolean => {
  return Boolean(
    draggedItem && draggedItem.type === itemType && draggedItem.id === id
  );
};

Cette fonction compare le type et l'ID de l'élément actuellement sélectionné avec le type et l'ID que nous passons en argument. Nous pouvons maintenant passer cet utilitaire dans notre composant Column.tsx :

 export const Column = ({ text, index, id }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  const ref = useRef<HTMLDivElement>(null);
  const { drag } = useItemDrag({ type: "COLUMN", id, index, text });
  const { drop } = useItemDrop(index);
  drag(drop(ref));
  return (
    <ColumnContainer
      ref={ref}
      isHidden={isHidden(state.draggedItem, "COLUMN", id)}
    >
      <ColumnTitle>{text}</ColumnTitle>
      {state.lists[index].tasks.map((task, i) => (
        <Card 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>
  );
};

Nous devrions obtenir le résultat suivant :

Effet lors du drag and drop

Si nous regardons attentivement, Trello utilise un effet légèrement incliné lors du glisser-déposer. La bibliothèque react-dnd nous permet d'implémenter un customDragLayer, qui nous permet de personnaliser l'élément que nous voulons déplacer. Ajoutons un nouveau conteneur dans Container.tsx appelé CustomDragLayerContainer :

 export const CustomDragLayerContainer = styled.div`
  height: 100%;
  left: 0;
  pointer-events: none;
  position: fixed;
  top: 0;
  width: 100%;
  z-index: 100;
`;

Nous voulons que ce conteneur soit au-dessus de tous les autres. C'est pourquoi nous utilisons un index aussi élevé. Un autre point important : pointer-events: none, qui nous permet d'ignorer tous les événements de souris. L'étape suivante consiste à créer un layer, ou un composant, qui utilise useDragLayer de react-dnd. Créons un nouveau composant dans notre dossier components :

 import { useDragLayer } from "react-dnd";
import { CustomDragLayerContainer } from "../styled-components/container";
import { Column } from "../components/column";

const CustomDragLayer: React.FC = () => {
  const { isDragging, item } = useDragLayer((monitor) => ({
    item: monitor.getItem(),
    isDragging: monitor.isDragging(),
  }));
  return isDragging ? (
    <CustomDragLayerContainer>
      <Column id={item.id} text={item.text} index={item.index} />
    </CustomDragLayerContainer>
  ) : null;
};

Dans notre code, useDragLayer nous permet d'obtenir isDragging, mais aussi l'élément en cours de déplacement. Nous utilisons une colonne pour prévisualiser le mouvement, tout en passant l'ID, l'indice et le texte de l'objet en cours de mouvement.

Preview

Nous allons créer une fonction qui nous permettra :

  • De coordonner l'objet en mouvement avec react-dnd
  • De générer les styles avec un attribut transform pour créer la prévisualisation.

Dans notre composant créé précédemment, nous pouvons créer cette fonction :

 import { XYCoord, useDragLayer } from "react-dnd";
import { CustomDragLayerContainer } from "../styled-components/container";
import { Column } from "../components/column";

function getItemStyles(currentOffset: XYCoord | null): React.CSSProperties {
  if (!currentOffset) {
    return {
      display: "none",
    };
  }
  const { x, y } = currentOffset;
  const transform = `translate(${x}px, ${y}px)`;
  return {
    transform,
    WebkitTransform: transform,
  };
}

La fonction prend en paramètre currentOffset de type XYCoord, qui contiendra la position de l'élément attrapé. Les positions de cet élément permettent de créer la CSS transform. Cette fonction doit être ajoutée dans un élément div autour de notre colonne qui nous sert de prévisualisation :

 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)}>
        <Column
          id={item.id}
          text={item.text}
          index={item.index}
          isPreview={true}
        />
      </div>
    </CustomDragLayerContainer>
  );
};
export default CustomDragLayer;

Maintenant que notre composant est défini, nous devons :

  • Monter le composant dans notre App.
   return (
    <AppContainer>
      <CustomDragLayer />
      {state.lists.map((list, i) => (
        <Column id={list.id} text={list.text} key={list.id} index={i} />
      ))}
      <AddNewItem
        toggleButtonText="+ Add another list"
        onAdd={(text) => dispatch({ type: "ADD_LIST", payload: text })}
      />
    </AppContainer>
  );
  • Cacher la prévisualisation par défaut en utilisant useEffect dans use-item-drag.ts :
 import { useEffect } from "react";
import { useDrag } from "react-dnd";
import { DragItem } from "../models/drag-item";
import { useAppState } from "../states/app-state-context";
import { getEmptyImage } from "react-dnd-html5-backend";

export const useItemDrag = (item: DragItem) => {
  const { dispatch } = useAppState();
  const [, drag, preview] = useDrag({
    item,
    begin: () =>
      dispatch({
        type: "SET_DRAGGED_ITEM",
        payload: item,
      }),
    end: () => dispatch({ type: "SET_DRAGGED_ITEM", payload: undefined }),
  });
  useEffect(() => {
    preview(getEmptyImage(), { captureDraggingState: true });
  }, [preview]);
  return { drag };
};

Nous avons réussi à cacher la prévisualisation par défaut en utilisant getEmptyImage. Notre prévisualisation est cachée parce que nous utilisons le même ID et le même indice pour notre colonne en cours de déplacement. Nous devons donc ajouter isPreview comme condition à notre fonction isHidden :

 import { DragItem } from "../models/drag-item";

export const isHidden = (
  isPreview: boolean | undefined,
  draggedItem: DragItem | undefined,
  itemType: string,
  id: string
): boolean => {
  return Boolean(
    !isPreview &&
      draggedItem &&
      draggedItem.type === itemType &&
      draggedItem.id === id
  );
};

Maintenant nous devons ajouter cette propriété à notre interface ColumnProps, et rajouter l'information dans notre composant Column :

 interface ColumnProps {
  text: string;
  index: number;
  id: string;
  isPreview?: boolean;
}
export const Column = ({ text, index, id, isPreview }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  const ref = useRef<HTMLDivElement>(null);
  const { drag } = useItemDrag({ type: "COLUMN", id, index, text });
  const { drop } = useItemDrop(index);
  drag(drop(ref));
  return (
    <ColumnContainer
      ref={ref}
      isHidden={isHidden(isPreview, state.draggedItem, "COLUMN", id)}
    >

Nous devons également changer l'opacité de notre conteneur tout en ajoutant la propriété isPreview :

 interface DragPreviewContainerProps {
  isHidden?: boolean;
  isPreview?: boolean;
}

export const DragPreviewContainer = styled.div<DragPreviewContainerProps>`
  transform: ${(props) => (props.isPreview ? "rotate(5deg)" : undefined)};
  opacity: ${(props) => (props.isHidden ? 0 : 1)};
`;

Si tout se passe bien voici le résultat:

Fin de la partie 3

Nous avons désormais la possibilité de faire glisser et déposer nos colonnes. Dans le prochain chapitre, nous allons :

  • Poursuivre notre glisser-déposer pour nos cartes.
  • Créer des cas pour différencier le glisser-déposer de nos cartes et de nos colonnes.

La suite de ce tutoriel se trouve ici : React - Trello - Part 4.

Pierre Colart

Developpeur et architecte passionné, qui souhaite partagé son univers et ses découvertes afin de rendre les choses plus simple pour chacun

Voir le profil

URLs

Check les divers liens pour cet article

Web - La demo en un click

Les derniers articles

Sequences, Time Series et Prediction

© 2023 Switch case. Made with by Pierre Colart