/** * Panneau des paramètres d'un projet : nom, couleur, tags. * * Jumeau de `detail.js`, et volontairement : renommer un projet et renommer une * phase sont la même opération à un niveau de granularité près. Elles se * présentent donc pareil — même panneau latéral, même édition au fil de la * saisie, même bouton de suppression au bas du formulaire. * * Ces paramètres passaient auparavant par un `` modal à valider. Voir * docs/decisions.md, section 21. */ import { creerBlocNotes } from './notes.js'; import { LIBELLES_ETAT, decalerHorizon, definirHorizon, ecarterProjet, etatProjet, formaterDateLongue, formaterHorizon, moisValide, projetADater, reanimerProjet, rouvrirProjet, terminerProjet, } from './model.js'; export function creerPanneauProjet(refs, outils, rappels) { const { panneau, formulaire, titre, erreur, conteneurCouleurs, suggestions, blocSuggestions, etat, blocHorizon, crans, aideHorizon, terminer, ecarter, notes, supprimer, fermer, } = refs; const { couleurs, normaliserTags, cleTag, teinteTag } = outils; const blocNotes = creerBlocNotes(notes, { libelle: 'Notes du projet' }); let idCourant = null; let projetCourant = null; function afficherErreur(message) { erreur.textContent = message || ''; erreur.hidden = !message; } /** Tags saisis dans le champ, tels qu'ils seront enregistrés. */ function tagsSaisis() { return normaliserTags(formulaire.tags.value.split(',')); } /** * La palette est bâtie une fois pour toutes, et seul l'état pressé change * ensuite. La reconstruire à chaque frappe ferait perdre le focus à qui * parcourt les couleurs au clavier — et l'édition en direct provoque un * rafraîchissement à chaque caractère saisi dans le nom. */ function peindrePalette(choisie) { if (!conteneurCouleurs.childElementCount) { for (const couleur of couleurs) { const bouton = document.createElement('button'); bouton.type = 'button'; bouton.className = 'pastille'; bouton.dataset.couleur = couleur; bouton.style.background = couleur; bouton.setAttribute('aria-label', `Couleur ${couleur}`); // Une couleur se choisit d'un clic et s'applique aussitôt : la frise // change sous les yeux, ce qui est le seul moyen de juger si la teinte // se distingue bien de celle des couloirs voisins. bouton.addEventListener('click', () => appliquer({ color: couleur })); conteneurCouleurs.append(bouton); } } for (const bouton of conteneurCouleurs.children) { bouton.setAttribute('aria-pressed', String(bouton.dataset.couleur === choisie)); } } /** * Tags déjà employés ailleurs dans le planning, proposés en un clic. * * Sans eux, chaque projet inventerait sa propre orthographe et le filtre se * remplirait de « client », « Clients » et « client final ». */ function peindreSuggestions() { const tagsConnus = rappels.tagsConnus(); // La liste change dès qu'un tag apparaît ou disparaît du planning : on la // reconstruit, mais on rend le focus au bouton de même rang, sans quoi // cliquer une suggestion au clavier éjecterait vers le début du document. const focalise = [...suggestions.children].indexOf(document.activeElement); suggestions.replaceChildren(); const posees = new Set(tagsSaisis().map(cleTag)); for (const tag of tagsConnus) { const bouton = document.createElement('button'); bouton.type = 'button'; bouton.className = 'tag'; bouton.style.setProperty('--teinte-tag', String(teinteTag(tag))); bouton.textContent = tag; bouton.setAttribute('aria-pressed', String(posees.has(cleTag(tag)))); bouton.addEventListener('click', () => { const saisis = tagsSaisis(); const restants = saisis.filter((pose) => cleTag(pose) !== cleTag(tag)); const suivants = restants.length === saisis.length ? [...saisis, tag] : restants; formulaire.tags.value = normaliserTags(suivants).join(', '); appliquer(); }); suggestions.append(bouton); } if (focalise >= 0) suggestions.children[focalise]?.focus(); blocSuggestions.hidden = tagsConnus.length === 0; } /** * Horizon lu dans les deux champs de mois. * * Une borne laissée vide prend la valeur de l'autre : on saisit souvent un * mois unique, et exiger de le taper deux fois serait une formalité. Les deux * vides valent « pas d'horizon » — un état légal, celui d'un projet hérité * d'un fichier en version 4, qui se signalera comme réclamant une date. * * Le champ `type="month"` n'est pas un sélecteur partout : sous Firefox c'est * un champ texte, d'où la vérification plutôt qu'une confiance faite au * navigateur. */ function horizonSaisi() { const debut = formulaire.horizonStart.value; const fin = formulaire.horizonEnd.value; if (!moisValide(debut) && !moisValide(fin)) return undefined; if (!moisValide(debut)) return { start: fin, end: fin }; if (!moisValide(fin)) return { start: debut, end: debut }; return definirHorizon({}, debut, fin).horizon; } /** * Le bloc « Horizon » ne paraît que pour un projet envisagé. * * Dès qu'une phase existe, ce sont les phases qui commandent l'emprise et * l'horizon n'est plus qu'une valeur de repli conservée en silence : la * proposer à la saisie laisserait croire qu'elle déplace encore quelque chose. */ function peindreCycleDeVie(projet) { const nom = etatProjet(projet); const envisage = nom === 'considered'; etat.textContent = LIBELLES_ETAT[nom] + legendeEtat(projet, nom); etat.dataset.etat = nom; // Chaque acte se présente dans un sens ou dans l'autre selon où en est le // projet : une commande qui bascule plutôt que deux dont l'une serait // toujours grise. terminer.textContent = nom === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé'; // Un projet envisagé n'a rien à clore, et un écarté sort du cimetière par // « Remettre en jeu » avant tout autre chose. terminer.disabled = nom === 'considered' || nom === 'discarded'; terminer.title = terminer.disabled ? nom === 'considered' ? "Un projet envisagé n'a pas encore commencé." : "Remettre le projet en jeu d'abord." : nom === 'completed' ? 'Les phases resteront terminées : leur statut d’avant est perdu.' : 'Toutes les phases et tous les jalons passeront à « Terminé ».'; ecarter.textContent = nom === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet'; ecarter.title = nom === 'discarded' ? "Le projet retrouvera l'état que ses données commandent." : 'Le projet quitte la frise mais garde tout, notes comprises.'; blocHorizon.hidden = !envisage; if (!envisage) return; // On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur au // milieu d'une frappe, comme pour le nom et les tags. const actif = document.activeElement; if (formulaire.horizonStart !== actif) { formulaire.horizonStart.value = projet.horizon?.start ?? ''; } if (formulaire.horizonEnd !== actif) { formulaire.horizonEnd.value = projet.horizon?.end ?? ''; } const aDater = projetADater(projet); aideHorizon.textContent = aDater ? projet.horizon ? 'Cet horizon est passé : le repousser, ou écarter le projet.' : "Sans horizon, ce projet n'est situé nulle part sur la frise." : 'Ajouter une phase engage le projet et lui donne des dates fermes.'; aideHorizon.dataset.alerte = String(aDater); // Rien à décaler tant qu'aucun horizon n'est posé : les crans conservent la // largeur d'un intervalle, ils n'en inventent pas. for (const bouton of crans.children) bouton.disabled = !projet.horizon; } /** Ce qui suit le nom de l'état : l'horizon, ou la date de l'acte. */ function legendeEtat(projet, nom) { if (nom === 'considered') { return projet.horizon ? ` — ${formaterHorizon(projet.horizon)}` : ' — sans horizon'; } if (nom === 'engaged') { return ` — ${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}`; } if (nom === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`; return ` le ${formaterDateLongue(projet.discardedDate)}`; } function remplir(projet) { titre.textContent = projet.name; formulaire.name.value = projet.name; formulaire.tags.value = projet.tags.join(', '); blocNotes.definir(projet.notes, { reinitialiserMode: true }); peindrePalette(projet.color); peindreSuggestions(); peindreCycleDeVie(projet); afficherErreur(null); } /** * Lit le formulaire et remonte le changement. * * @param {object} forces champs imposés hors formulaire — la couleur, qui se * choisit dans la palette et n'a pas de champ de saisie. */ function appliquer(forces = {}) { if (!idCourant || !projetCourant) return; const nom = formulaire.name.value.trim(); if (!nom) { // Le projet garde son nom précédent : on signale sans rien écraser, comme // le fait le panneau d'une phase. afficherErreur('Le nom ne peut pas être vide.'); return; } afficherErreur(null); const suivant = { ...projetCourant, name: nom, tags: tagsSaisis(), notes: formulaire.notes.value, // L'horizon d'un projet engagé n'est plus modifiable, mais il n'est pas // effacé pour autant : c'est ce qui permet à un projet ayant perdu sa // dernière phase de le retrouver plutôt qu'une enveloppe reconstituée. horizon: etatProjet(projetCourant) === 'considered' ? horizonSaisi() : projetCourant.horizon, ...forces, }; // Comparaison terme à terme plutôt que par concaténation : un tag peut // contenir une espace, et « a b » ne doit pas passer pour « a », « b ». const memesTags = suivant.tags.length === projetCourant.tags.length && suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]); const memeHorizon = suivant.horizon?.start === projetCourant.horizon?.start && suivant.horizon?.end === projetCourant.horizon?.end; if ( suivant.name === projetCourant.name && suivant.color === projetCourant.color && suivant.notes === projetCourant.notes && // Les deux dates d'acte comptent ici comme les autres champs : sans elles, // déclarer un projet terminé ne changerait aucune des valeurs comparées et // la modification serait tue. suivant.completedDate === projetCourant.completedDate && suivant.discardedDate === projetCourant.discardedDate && memesTags && memeHorizon ) { return; } projetCourant = suivant; titre.textContent = suivant.name; peindrePalette(suivant.color); peindreSuggestions(); peindreCycleDeVie(suivant); rappels.surModification(idCourant, suivant); } formulaire.addEventListener('input', () => appliquer()); formulaire.addEventListener('change', () => appliquer()); formulaire.addEventListener('submit', (evenement) => evenement.preventDefault()); // Repousser par crans est le seul geste qui déplace un horizon : la barre de // la frise, elle, ne se glisse pas. Les trois boutons couvrent ce qu'on fait // réellement — reculer d'un trimestre, en gagner un, renvoyer à l'an prochain. crans.addEventListener('click', (evenement) => { const bouton = evenement.target.closest('[data-cran]'); if (!bouton || !projetCourant?.horizon) return; appliquer({ horizon: decalerHorizon(projetCourant, Number(bouton.dataset.cran)).horizon }); }); // Les deux actes passent par `appliquer`, comme le reste du panneau : c'est // lui qui compare, remonte la modification et repeint. terminer.addEventListener('click', async () => { if (!projetCourant) return; if (etatProjet(projetCourant) === 'completed') { appliquer(rouvrirProjet(projetCourant)); return; } // Clore termine aussi les phases : la confirmation vit dans app.js, qui la // partage avec l'entrée du menu ⋯ — le même acte ne doit pas prévenir depuis // un endroit et se taire depuis l'autre. const cible = idCourant; if (!(await rappels.demanderCloture(projetCourant))) return; // Le panneau a pu être fermé ou changer de projet pendant la confirmation. if (idCourant !== cible || !projetCourant) return; appliquer(terminerProjet(projetCourant)); }); ecarter.addEventListener('click', () => { if (!projetCourant) return; const nom = etatProjet(projetCourant); appliquer(nom === 'discarded' ? reanimerProjet(projetCourant) : ecarterProjet(projetCourant)); }); supprimer.addEventListener('click', async () => { if (!idCourant) return; const cible = idCourant; const projet = projetCourant; if (!(await rappels.demanderSuppression(cible, projet))) return; // Le panneau a pu être fermé ou changer de projet pendant la confirmation. if (idCourant !== cible) return; api.fermer(); rappels.surSuppression(cible); }); fermer.addEventListener('click', () => api.fermer()); document.addEventListener('keydown', (evenement) => { if (evenement.key === 'Escape' && !panneau.hidden) api.fermer(); }); const api = { /** * @param {object} options * - nomAEcraser : place le curseur dans le champ « Nom », texte * présélectionné. Réservé à un projet qui vient d'être créé, dont le nom * provisoire est justement là pour être remplacé. */ ouvrir(projet, { nomAEcraser = false } = {}) { idCourant = projet.id; projetCourant = projet; remplir(projet); panneau.hidden = false; if (nomAEcraser) { formulaire.name.focus(); formulaire.name.select(); } else { panneau.focus(); } }, /** Recharge le contenu après une modification venue d'ailleurs. */ rafraichir(projet) { if (idCourant !== projet.id) return; projetCourant = projet; titre.textContent = projet.name; // On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur // au milieu d'une frappe. const actif = document.activeElement; if (formulaire.name !== actif) formulaire.name.value = projet.name; if (formulaire.tags !== actif) formulaire.tags.value = projet.tags.join(', '); if (formulaire.notes !== actif) blocNotes.definir(projet.notes); peindrePalette(projet.color); peindreSuggestions(); // L'état a pu changer sans passer par ce panneau : ajouter la première // phase engage le projet, et le bloc « Horizon » doit disparaître au même // instant que la barre pâle sur la frise. peindreCycleDeVie(projet); }, fermer() { if (panneau.hidden) return; idCourant = null; projetCourant = null; panneau.hidden = true; rappels.surFermeture(); }, estOuvert() { return idCourant !== null; }, projetOuvert() { return idCourant; }, }; return api; }