Jan 16, 2020 Javascript

Maîtriser Javascript : une liste de tâches V2

Dans un précédent article j’ai montré comment créer une liste de tâches très simple avec Javascript sans utilisation de librairie. Dans le présent article je reviens sur cette application en la faisant évoluer. On va l’enrichir et surtout organiser le code en modules pour plus de clarté.

J’ai mis une démo ici.

D’autre part le code source final est téléchargeable ici.

Les fonctionnalités

On va coder les fonctionnalités suivantes :

  • ajout d’une tâche dans avec une zone de saisie
  • test de tâche déjà existante et affichage d’une alerte
  • changement d’état d’une tâche (marquer/démarquer) avec un bouton
  • suppression d’une tâche avec un bouton
  • modification du texte d’une tâche avec un double clic sur le texte et apparition d’une zone de saisie
  • affichage du nombre de tâches restantes
  • boutons de sélection pour afficher soit :
    • toutes les tâches
    • les tâches restantes
    • les tâches achevées
  • suppression de toutes les tâches achevées avec un bouton
  • apparition d’un boîte modale d’aide avec un bouton

La maquette statique

Je commence toujours par faire une maquette statique pour régler la structure et le style. J’utilise encore Paper CSS. Voici le code de cette maquette :

<!doctype html>
<html lang="fr">

<head>
  <meta charset="utf-8">
  <title>Ma Liste de tâches</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="https://unpkg.com/papercss/dist/paper.min.css">
  <style>
    button, .paper-btn { 
      float: right;
      margin: 0 0 0 5px;  
    }

    h1 {
      text-align: center;
      margin-top: 0.2em;
    }

    .form-group {
      text-align: center;
      margin-top: 1em;
      padding: 8px;
    }

    .form-group .paper-radio {
        display: inline-block;
        margin-right: 1em;
      }
    
    input {
      width: 60%;
    }
  </style>
</head>

<body>
  <br>
  <div class="paper container">
    <h1 class="shadow border border-primary">Ma liste de tâches</h1>
    <div id="alert">
      <input class="alert-state" id="alert" type="checkbox">
      <div class="alert alert-danger dismissible">
        Cette tâche existe déjà !
        <label class="btn-close" for="alert">X</label>
      </div>
    </div>
    <div class="row flex-edges">
      <input id="add" type="text" placeholder="Nouvelle tâche">
      <label class="paper-btn btn-small btn-secondary" for="aide">Aide</label>
    </div>
    <input class="modal-state" id="aide" type="checkbox">
    <div class="modal">
      <label class="modal-bg" for="aide"></label>
      <div class="modal-body">
        <label class="btn-close" for="aide">X</label>
        <h4 class="modal-title">Un peu d'aide !</h4>
        <h5 class="modal-subtitle">Ajouter une tâche</h5>
        <p class="modal-text">Entrez le texte dans la zone de saisie et utilisez la touche "Entrée" pour valider.</p>
        <h5 class="modal-subtitle">Modifier une tâche</h5>
        <p class="modal-text">Pour changer l'état utilisez le bouton "Marquer/Démarquer".</p>
        <p class="modal-text">Pour changer le texte faites un double clic sur celui-ci et utilisez la zone de saisie qui apparait.</p>
        <h5 class="modal-subtitle">Supprimer une tâche</h5>
        <p class="modal-text">Utilisez le bouton rouge "Supprimer".</p>
        <h5 class="modal-subtitle">Filtrer les tâches</h5>
        <p class="modal-text">Utilisez les boutons radio en bas de la page.</p>
        <h5 class="modal-subtitle">Purger les tâches terminées</h5>
        <p class="modal-text">Utilisez le bouton jaune en bas à droite de la page.</p>
      </div>
    </div>
    <table>
      <tbody>
        <tr>
          <td>tache 1</td>   
          <td>
            <button class="btn-small btn-danger">Supprimer</button>
            <button class="btn-small marquer">Marquer</button>
          </td>
        </tr>   
        <tr>
          <td>tache 2</td>   
          <td>
            <button class="btn-small btn-danger">Supprimer</button>
            <button class="btn-small demarquer">Démarquer</button>
          </td>
        </tr>      
      </tbody>
    </table>
    <div id="filters" class="form-group">        
      <label for="all" class="paper-radio">
        <input type="radio" name="filter" id="all" value="all" checked>
        <span>Tous<span>
      </label>
      <label for="actifs" class="paper-radio">
        <input type="radio" name="filter" id="actifs" value="actifs">
        <span>Actifs<span>
      </label>
      <label for="completed" class="paper-radio">
        <input type="radio" name="filter" id="completed" value="completed">
        <span>Finis<span>
      </label>         
    </div> 
    <div id="footer" class="row flex-edges">
      <div><span class="badge">Reste 1</span></div>
      <button class="btn-small btn-warning">Purger tâches finies</button>
    </div>    
  </div>
</body>

</html>

Avec cet aspect sur grand écran :

Et une bonne réactivité sur écran étroit :

Je n’insiste pas sur cette partie qui ne concerne pas Javascript. Notez que la boîte modale pour l’aide fonctionne en pur CSS.

On va avoir 4 zones dynamiques :

Il va donc falloir en gros :

  • gérer le tableau des tâches existantes en affichant le nom de tâches et les boutons adaptés et intercepter les éventements de la souris
  • afficher les filtres quand il y a des tâches et réagir aux actions
  • afficher le pied de page en actualisant le nombre de tâches actives et en prévoyant le bouton de purge quand il y a des tâches finies
  • afficher l’alerte quand on entre une tâche déjà existante et la supprimer avec un clic

Pour le HTML du projet on va prendre ce fichier en se contenant de retirer la partie style qui sera dans un fichier distinct.

Webpack

Pour l’empaquetage on va utiliser Webpack donc j’ai longuement parlé dans le précédent article. Pour se simplifier la vie on va utiliser ce template qui sera parfait pour notre usage. il permet de :

  • passer de ES6 en ES5 avec babel
  • gérer les fichiers Sass
  • utiliser un linter
  • séparer le CSS résultant dans un fichier séparé et créer le lien dans le HTML

On dispose de deux commandes :

  • npm start : démarrage du serveur de Webpack et version de développement du projet
  • npm run build : création des fichiers dans un dossier build

Organisation du code

On va adopter le pattern MVC pour organiser le code, en voici un résumé succinct :

  • Modèle : mémorisation et gestion des tâches
  • Vue : gestion de l’affichage
  • Contrôleur : réception des actions de l’utilisateur et commande du modèle et de la vue

On va ajouter la gestion des événements du navigateur. Au final voilà l’architecture :

J’ai créé des dossiers pour bien séparer les fonctionnalités. Tout le code est réparti en modules.

En ce qui concerne le CSS on a un fichier Sass avec ce code :

button, .paper-btn { 
  float: right;
  margin: 0 0 0 5px;  
}

h1 {
  text-align: center;
  margin-top: 0.2em;
}

.form-group {
  text-align: center;
  margin-top: 1em;
  padding: 8px;

  .paper-radio {
    display: inline-block;
    margin-right: 1em;
  }
}

input {
  width: 60%;
}

Webpack va s’en occuper pour nous !

Il aurait été sans doute judicieux de charger le source de Paper.css avec npm, de faire les modifications dessus et d’avoir ainsi un fichier unique à télécharger. Mais comme c’est en dehors du champ de cet article je ne suis pas allé jusque là pour simplifier la lecture.

Événements et routage

Pour analyser ce projet on peut s’y prendre de deux manières : soit partir des données et progresser jusqu’aux interactions avec l’utilisateur, soit l’inverse. C’est cette deuxième possibilité que je vais adopter et donc commencer par les événements.

La mise en place des écoutes se situe dans le fichier src/events.js :

// Wrapper d'écoute
const wrapper = (selector, type, callback, condition = 'true', capture = false) => {
  document.querySelector(selector).addEventListener(type, e => { 
    if(eval(condition)) {
      callback(e.target);
    }
  }, capture);
};

// Appui touche "Entrée" dans input ajout de tâche
const addEnter = (callback) => wrapper(
  '.container',
  'keydown',
  callback,
  "e.key === 'Enter' && e.target.matches('#add')"
);

// Double clic sur un texte de tâche
const textDblClick = (callback) => wrapper(
  'table',
  'dblclick',
  callback,
  "e.target.childNodes.length === 1"
);

// Appui touche "Entrée" dans input changement de texte
const editEnter = (callback) => wrapper(
  '.container',
  'keydown',
  callback,
  "e.key === 'Enter' && e.target.matches('#edit')"
);

// Appui touche "Escape" dans input changement de texte
const editEscape = (callback) => wrapper(
  '.container',
  'keydown',
  callback,
  "e.key === 'Escape' && e.target.matches('#edit')"
);

// Perte du focus dans input changement de texte
const containerBlur = (callback) => wrapper(
  '.container',
  'blur',
  callback,
  "e.target.matches('#edit')",
  true
);

// Clic sur la croix de fermeture de l'alerte
const dismissClick = (callback) => wrapper(
  '.btn-close',
  'click',
  callback
);

// Clic sur un bouton toogle (marque-démarque)
const toggleClick = (callback) => wrapper(
  'table',
  'click',
  callback,
  "e.target.matches('button') && !e.target.matches('.btn-danger')"
);

// Clic sur un bouton de suppression
const delClick = (callback) => wrapper(
  'table',
  'click',
  callback,
  "e.target.matches('button') && e.target.matches('.btn-danger')"
);

// Clic sur les filtres
const filtersClick = (callback) => wrapper(
  '#filters',
  'click',
  callback,
  'e.target.matches(\'input[type="radio"]\')'
);

// Clic sur le bouton de purge
const cleanClick = (callback) => wrapper(
  '#footer',
  'click',
  callback,
  "e.target.matches('button')"
);

// Chargement de la page
const pageLoad = (callback) => window.addEventListener('load', () => callback()); 

export {
  containerBlur,
  addEnter,
  editEnter,
  editEscape,
  dismissClick,
  toggleClick,
  delClick,
  textDblClick,
  filtersClick,
  cleanClick,
  pageLoad
};

J’ai commenté les différentes actions qui sont interceptées, il y en a pas mal. Pour la lisibilité du code j’ai mis en place un wrapper :

const wrapper = (selector, type, callback, condition = 'true', capture = false) => {
  document.querySelector(selector).addEventListener(type, e => { 
    if(eval(condition)) {
      callback(e.target);
    }
  }, capture);
};

Pour chaque écoute on a donc :

  • un sélecteur
  • un type d’événement (click, keydown…)
  • une fonction de rappel (callback)
  • une condition éventuelle
  • une capture éventuelle (pour inverser la propagation de l’événement)

Comme on va avoir des éléments dynamiques il y a deux solutions dans ce cas :

  • soit on attache les événements à chaque fois qu’on crée un élément nouveau
  • soit on intercepte les événements dans un parent non dynamique parce qu’on sait qu’il y a propagation des événements, mais dans ce cas il faut un test sur l’élément émetteur (target) pour savoir où l’action a eu lieu

J’ai opté pour la deuxième possibilité. Ainsi on peut mettre en place toutes les écoutes nécessaires au départ et ne plus y revenir.

Le fichier d’entrée de l’application est src/index.js :

Dans ce fichier on va importer le fichier Sass pour qu’il soit traité par Webpack :

import '../styles/index.scss';

On va importer le contrôleur et les écoutes :

import * as controller from './controller/index';
import * as events from './events';

Ensuite on va faire du routage selon les événements vers les bonnes méthodes du contrôleur :

// Perte du focus dans l'input de modification du texte
events.containerBlur(controller.escapeEdit);

// Entrée pour un ajout de tâche
events.addEnter(controller.addTask);

// Entrée pour une modification de tâche
events.editEnter(controller.updateTask);

// Escape pour une modification de tâche
events.editEscape(controller.escapeEdit);

// Clic sur dismiss alerte
events.dismissClick(controller.alertDismiss);

// Clic sur bouton toggle tâche
events.toggleClick(controller.toggleTask);

// Clic sur bouton suppression tâche
events.delClick(controller.delTask);

// Double click sur texte de tâche
events.textDblClick(controller.editTask);

// Clic sur les filtres
events.filtersClick(controller.filterChanged);

// Clic bouton de purge
events.cleanClick(controller.clean);

// Chargement de la page
events.pageLoad(controller.pageLoaded);

Ainsi les choses sont bien claires ! On voit qu’on a 11 actions répertoriées avec pour chacune une action correspondante du contrôleur.

Le contrôleur

Le contrôleur est le chef d’orchestre de l’application :

Son point d’entrée est index.js et il a un helper pour deux méthodes. Voyons cet helper :

export const getFilter = () => document.querySelector('input[name="filter"]:checked').value;
export const getText = target => target.parentNode.previousElementSibling.innerText;

La première méthode retourne le filtre sélectionné et la seconde le texte de la tâche.

Voyons un peu le code du contrôleur (controller/index.js). Quelques imports :

import * as tasks from '../model/index';
import * as view from '../view/index';
import * as helpers from './helpers';

Le modèle, la vue et les helpers.

On a aussi des routines :

// Remplissage de la liste
const fillList = () => view.fill(tasks.getFiltered(helpers.getFilter()));

// Mise à jour du footer
const updateFooter = () => view.updateFooter(tasks.checkTasks(), tasks.checkComplete());

// Reset de la liste
const reset = () => {
  fillList();
  updateFooter(); 
};

// Chargement de la page
const pageLoaded = () => {
  tasks.init();
  reset();
};

Le reste du code est constitué des méthodes appelées par le routeur vu ci-dessus :

let editText = null;

// Annulation alerte
const alertDismiss = () => view.alert(false);

// Echappement lors de l'edit
const escapeEdit = target => view.escapeEdit(target, editText, tasks.getValue(editText));

// Changement du filtre, on remplit la liste avec les tâches filtrées
const filterChanged = target => view.fill(tasks.getFiltered(target.value));

// Ajout d'une tâche
const addTask = target => {
  // Le texte
  const value = target.value;
  // Si le texte n'est pas vide
  if(value !== '') {
    // On vérifie que la tâche n'existe pas
    if(tasks.hasTask(value)) {
      // Alerte si la tâche existe déjà
      view.alert(true);
    } else {
      // On ajoute la tâche dans le dictionnaire
      tasks.add(value, false);
      // On ajoute la tâche dans le tableau
      view.addLine(value, false);
      // Footer
      updateFooter();
    }
  }
};

// Marquage
const toggleTask = target => {
  // Changement dans le dictionnaire
  tasks.update(helpers.getText(target), target.matches('.marquer'));
  // Rafraichissement de la liste 
  reset();
};

// Suppression
const delTask = target => {
  // Suppression dans le dictionnaire
  tasks.del(helpers.getText(target));
  // Suppression dans la vue
  view.delLine(target);   
  // Footer 
  updateFooter();
};

// Edition d'une tâche
const editTask = target => {
  editText = target.textContent;
  view.editLine(target);
};

// Modification d'une tâche
const updateTask = target => {
  const text = target.value;
  tasks.changeKey(editText, text); 
  view.escapeEdit(target, text, tasks.getValue(text)); 
};

// Purge de la liste
const clean = () => {
  tasks.clean();
  reset();   
};

La variable editText sert à mémoriser la tâche dont on est en train de changer le texte. En effet en cas d’annulation on doit pouvoir le régénérer.

On a enfin les exports :

export { 
  addTask, 
  alertDismiss, 
  toggleTask,
  delTask,
  filterChanged, 
  pageLoaded, 
  editTask, 
  updateTask, 
  escapeEdit,
  clean
};

Les méthodes font appel au modèle (tasks) et à la vue (view). Prenons un exemple avec la suppression d’une tâche :

// Suppression
const delTask = target => {
  // Suppression dans le dictionnaire
  tasks.del(helpers.getText(target));
  // Suppression dans la vue
  view.delLine(target);   
  // Footer 
  updateFooter();
};

On commence par supprimer la tâche dans le modèle :

tasks.del(helpers.getText(target));

On voit qu’on récupère le texte de la tâche avec un helper.

Ensuite on actualise la vue :

view.delLine(target);   
updateFooter();

Il y a deux actions dans la vue :

  • on enlève la ligne dans la tableau
  • on met à jour le bas de la page (filtres et footer)

On voit que le contrôleur n’a aucune idée de comment les choses se font dans le modèle et la vue, il se contente de leur dire quoi faire.

Le modèle

Le modèle se situe dans le dossier model :

Son rôle ets de gérer les données de l’application, en l’occurrence les tâches.

Dans le module storage on a la gestion du local storage :

export const setStorage = tasks => localStorage.setItem('TASKS', JSON.stringify(Array.from(tasks)));
export const getStorage = () => JSON.parse(localStorage.getItem('TASKS'));

Une méthode pour sauvegarder et une autre pour récupérer les données.

le modèle se trouve dans le module model/index. On importe le storage :

import * as storage from './storage';

On crée le dictionnaire qui va contenir les tâches :

let tasks = new Map();

Ensuite on a les 11 méthodes de gestion des tâches avec leur export :

// Initialisation à partir du storage
const init = () => {
  const values = storage.getStorage();
  if(values) {
    tasks = new Map(values);
  }
};

// Actualisation du storage
const save = () => storage.setStorage(tasks);

// test existence tâche
const hasTask = key => tasks.has(key);
 
// Ajout d'une tâche
const add = (key, value) => {
  // On ajoute la tâche
  tasks.set(key, value);
  // Actualisation du local storage
  save(); 
};

// Suppression d'une tâche
const del = key => {
  tasks.delete(key);
  // Actualisation du local storage
  save(); 
};

// Mise à jour d'une tâche
const update = (key, value) => {
  tasks.set(key, value);
  // Actualisation du local storage
  save();   
};

// Retour valeur à partir d'une clé
const getValue = key => tasks.get(key); 

// Changement clé
const changeKey = (key, newKey) => {
  // Transformation dictionnaire en tableau
  const elements = Array.from(tasks);
  // Changement de la clé
  elements[elements.findIndex(t => t[0] === key)][0] = newKey;
  // Transformation en dictionnaire
  tasks = new Map(elements);
  // Actualisation du local storage
  save();    
};

// Filtre selon l'état
const getFiltered = filter => {
  return filter === 'all' ? tasks : new Map(Array.from(tasks).filter((task) => task[1] === (filter === 'completed')));
}; 

// Purge les tâches finies
const clean = () => {
  tasks = getFiltered('actifs');
  save();
};

// Nombe de tâches finies
const checkComplete = () => getFiltered('completed').size;

// Nombre de tâches
const checkTasks = () => tasks.size;

export { 
  init, 
  add, 
  del, 
  update, 
  getFiltered, 
  getValue,
  changeKey,
  clean,
  checkComplete,
  checkTasks,
  hasTask
};

Dans un dictionnair on ne peut pas changer les clés, pourtant on aune méthode changeKey pour le faire. En effet on veut pouvoir changer le texte des tâches. Alors on transforme le dictiionnaire en tableau :

const elements = Array.from(tasks);

On peut alors changer la clé :

elements[elements.findIndex(t => t[0] === key)][0] = newKey;

On transforme le tableau en dictionnaire :

tasks = new Map(elements);

In ne reste plus qu’à mettre à jour le local storage :

save();

La vue

La vue se situe dans le dossier view :

Son rôle est de mettre à jour la page HTML.

Dans le module newline on a le template pour générer une nouvelle ligne du tableau :

export default (text, complete) => `
<tr>
  <td>${ complete ? `<del>${ text }</del>` : text}</td>   
  <td>
    <button class="btn-small btn-danger">Supprimer</button>
    ${ complete ? 
      '<button class="btn-small demarquer">Démarquer</button>' :
      '<button class="btn-small marquer">Marquer</button>'
    }            
  </td>
</tr>
`;

On voit que selon que la tâche est finie ou pas (complete) on va adapter l’affichage. Une tâche active a cet aspect :

Alors qu’une tâche active se présente ainsi :

Voici le code de la vue (module view/index) :

import newline from './newline';

// Création élément avec sélecteur
const qs = (selector) => document.querySelector(selector);

// Gestion de l'alerte
const alert = state => qs('#alert').style.display = state ? 'block' : 'none';

// Ajout d'une ligne et purge de l'input
const addLine = (text, complete) => {
  qs('tbody').innerHTML += newline(text, complete);
  qs('#add').value = '';
  alert(false);
};

// Suppression d'une ligne
const delLine = target => target.parentNode.parentNode.remove();

// Modification d'une ligne
const editLine = target => {
  target.parentNode.innerHTML = `<td><input id="edit" type="text" placeholder="toto" value="${ target.textContent }"><td>`;
  qs('#edit').focus();
};

// Annulation édition
const escapeEdit = (target, text, complete) =>  target.parentNode.parentNode.innerHTML = newline(text, complete);

// Remplissage de la liste
const fill = tasks => {  
  const tbody = document.createElement('tbody'); 
  Array.from(tasks).map(([text, complete]) => tbody.innerHTML += newline(text, complete));
  qs('tbody').replaceWith(tbody);
};

// Actualise le bas de page
const updateFooter = (numberTasks, numberComplete) => {
  // IL y a des tâches
  if(numberTasks) {
    if(!qs('#filters').childNodes.length) {
      qs('#filters').innerHTML =
      `
      <div>
        <label for="all" class="paper-radio">
          <input type="radio" name="filter" id="all" value="all" checked>
          <span>Tous<span>
        </label>
        <label for="actifs" class="paper-radio">
          <input type="radio" name="filter" id="actifs" value="actifs">
          <span>Actifs<span>
        </label>
        <label for="completed" class="paper-radio">
          <input type="radio" name="filter" id="completed" value="completed">
          <span>Finis<span>
        </label>
      </div>
      `;
    }
    // On actualise le footer
    qs('#footer').innerHTML = `
      <div><span class="badge">Reste ${ numberTasks - numberComplete }</span></div>
      ${ numberComplete? '<button class="btn-small btn-warning">Purger tâches finies</button>' : '' }
    `;
  // Il n'y a pas de tâches
  } else {
    qs('#filters').innerHTML = '';
    qs('#footer').innerHTML = '';
  }
};

export { 
  alert, 
  addLine, 
  delLine, 
  fill, 
  editLine, 
  escapeEdit,
  updateFooter
};

Je ne rentre pas dans le détailde ce code. Je prends juste l’exemple de remplissage de la liste (fill) . On crée un nouvel élément tbody :

const tbody = document.createElement('tbody');

Ensuite on parcout toutes les tâches en générant à chaque fois une nouvelle ligne :

Array.from(tasks).map(([text, complete]) => tbody.innerHTML += newline(text, complete));

On voit qu’on transforme le dictionnaire en tableau pour pouvoir parcourir tous les éléments. Pour finir on remplace le tbody dans la page :

qs('tbody').replaceWith(tbody);

Vision globale

Prenons une étape avec un schéma pour avoir une vision globale du fonctionnement. Par exemple l’ajout d’une tâche :

On se rend ainsi mieux compte de la répartition du travail entre les différents constituants de l’application.

Conclusion

Le code de cette application n’est qu’une possibilité parmi de nombreuses possibles. Il a le mérite d’être correctement organisé ce qui simplifie la lecture, les modifications et les tests. Une autre possibilité serait une approche par composants.

On verra d’autres aspects dans de prochains articles.

Laisser un commentaire