diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js index 37438e8..a18f18f 100644 --- a/src/interface/libelles/participants.js +++ b/src/interface/libelles/participants.js @@ -7,7 +7,9 @@ // participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans // l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle // d'une valeur de la fiche ; participants.statut.* nomme chaque statut -// calculé (§ 4.2), par cleDeStatut. +// calculé (§ 4.2), par cleDeStatut ; participants.groupe.* porte le +// groupement de la liste : le sélecteur de chaque niveau, l'en-tête d'un +// groupe et ses commandes. // Un nombre suivi de son nom, au singulier pour 0 et 1. const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`; @@ -47,6 +49,20 @@ export const TABLE = new Map([ ['participants.grille.vide', 'Aucun participant. Ajoutez une personne, ou collez une liste dans la grille.'], ['participants.grille.aucunResultat', 'Aucune personne ne correspond au filtre.'], + // --- La liste groupée par une colonne ou plusieurs ------------------------ + ['participants.groupe.grouperPar', 'Grouper par'], + ['participants.groupe.puisPar', ({ niveau }) => `Puis par (niveau ${niveau})`], + ['participants.groupe.aucun', 'Aucun groupement'], + ['participants.groupe.aucunSousGroupe', 'Aucun sous-groupe'], + ['participants.groupe.statut', 'Statut'], + ['participants.groupe.vide', '(vide)'], + ['participants.groupe.statutInconnu', 'statut non calculé'], + ['participants.groupe.compte', ({ compte: n }) => compte(n, 'personne', 'personnes')], + ['participants.groupe.replier', 'Replier'], + ['participants.groupe.deplier', 'Déplier'], + ['participants.groupe.toutReplier', 'Tout replier'], + ['participants.groupe.toutDeplier', 'Tout déplier'], + // --- Les commandes de la section ------------------------------------------ ['participants.commande.fiche', 'Fiche'], ['participants.commande.exclure', 'Exclure'], diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte index 8499ae0..2cddb69 100644 --- a/src/interface/participants/Grille.svelte +++ b/src/interface/participants/Grille.svelte @@ -30,14 +30,26 @@ // la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§ // 10.2) : le texte part à l'état de navigation, intact, et rien ne // s'écrit ici. + // + // Groupée (groupes non null, ceux de groupesGrille), la grille porte un + // corps par groupe du premier niveau. Chaque groupe, de tout niveau, y + // rend un en-tête, en retrait selon son niveau, dont le bouton replie ou + // déplie le groupe et dit son état en texte et dans aria-expanded, puis, + // déplié, ses sous-groupes, ou ses lignes au dernier niveau. Une ligne + // porte la clé du plus étroit groupe qui la compte. Les lignes visibles + // sont celles de lignesVisibles : flèches et Tab ne parcourent qu'elles, + // jamais un en-tête ni une ligne repliée. Une saisie validée dont la + // ligne quitte le rendu — tri ou groupe changé — rend le focus à la même + // cellule de sa nouvelle ligne, et chaque groupe replié qui la reçoit, de + // tout niveau, se déplie. Replier et déplier n'écrivent rien. import { getContext, onDestroy, tick } from 'svelte'; import { creerMachineChamp } from '../primitives/champ.js'; import Commande from '../primitives/Commande.svelte'; import { creerGeste } from '../primitives/geste.js'; import Refus from '../primitives/Refus.svelte'; - import { COLONNES, celluleSuivante, cleDeColonne, entreeValide } from './grille.js'; + import { COLONNES, basculerGroupe, celluleSuivante, cheminDe, cleDeColonne, entreeValide, lignesVisibles } from './grille.js'; - let { lignes, tri = $bindable(null), supprimer } = $props(); + let { lignes, groupes = null, replies = $bindable(new Set()), tri = $bindable(null), supprimer } = $props(); const t = getContext('t'); const seance = getContext('seance'); @@ -61,11 +73,13 @@ let tableau; let entree = $state(null); + // Les lignes que la grille montre, dans leur ordre, et le rang de chacune + // parmi elles, par identifiant. + const visibles = $derived(groupes === null ? lignes : lignesVisibles(groupes, replies)); + const rangs = $derived(new Map(visibles.map(({ id }, rang) => [id, rang]))); + // Le rang de la ligne active ; la première quand l'active n'est plus affichée. - const rangActif = $derived.by(() => { - const rang = lignes.findIndex(({ id }) => id === actif.id); - return rang < 0 ? 0 : rang; - }); + const rangActif = $derived(rangs.get(actif.id) ?? 0); const idRefus = (id, colonne) => `${uid}-refus-${id}-${colonne}`; const enEdition = (id, colonne) => edition !== null && edition.id === id && edition.colonne === colonne; @@ -91,7 +105,7 @@ } async function activer(rang, colonne) { - const ligne = lignes[rang]; + const ligne = visibles[rang]; actif = { id: ligne.id, colonne }; await tick(); cellule(ligne.id, COLONNES[colonne])?.focus(); @@ -146,8 +160,29 @@ } catch (erreur) { return echouer(enCours, id, colonne, erreur); } + const garderFocus = entree !== null && document.activeElement === entree; fermerEdition(); - return Promise.resolve(promesse).catch((erreur) => echouer(enCours, id, colonne, erreur)); + return Promise.resolve(promesse).then( + () => suivreLigne(id, colonne, garderFocus), + (erreur) => echouer(enCours, id, colonne, erreur), + ); + } + + // Après une modification écrite, quand la cellule avait le focus : la + // ligne que la valeur a menée sous des groupes repliés, de tout niveau, + // les déplie, puis la cellule reprend le focus que le retrait de son + // ancienne ligne a rendu au document. Un focus posé ailleurs entre-temps + // reste où il est. + async function suivreLigne(id, colonne, garderFocus) { + if (!garderFocus) return; + await tick(); + const chemin = groupes === null ? [] : cheminDe(groupes, id); + if (chemin.some((cle) => replies.has(cle))) { + replies = new Set([...replies].filter((cle) => !chemin.includes(cle))); + await tick(); + } + const actuel = document.activeElement; + if (actuel === null || actuel === document.body) cellule(id, colonne)?.focus(); } async function echouer(enCours, id, colonne, erreur) { @@ -202,10 +237,10 @@ // Tab mène à la première cellule de celle-ci, Maj+Tab à la dernière de la // ligne d'avant ; hors de la grille, le focus reste où il est. function tabulerDepuis(id, rangAvant, colonne, touche) { - const rang = lignes.findIndex((personne) => personne.id === id); + const rang = rangs.get(id) ?? -1; if (rang >= 0) return deplacer(rang, colonne, touche); const suivante = touche === 'Tab' ? { ligne: rangAvant, colonne: 0 } : { ligne: rangAvant - 1, colonne: COLONNES.length - 1 }; - if (suivante.ligne < 0 || suivante.ligne >= lignes.length) return false; + if (suivante.ligne < 0 || suivante.ligne >= visibles.length) return false; activer(suivante.ligne, suivante.colonne); return true; } @@ -216,7 +251,7 @@ } function deplacer(rang, colonne, touche) { - const suivante = celluleSuivante({ ligne: rang, colonne }, touche, { lignes: lignes.length, colonnes: COLONNES.length }); + const suivante = celluleSuivante({ ligne: rang, colonne }, touche, { lignes: visibles.length, colonnes: COLONNES.length }); if (suivante === null) return false; activer(suivante.ligne, suivante.colonne); return true; @@ -258,6 +293,117 @@ onDestroy(() => geste.liberer()); +{#snippet rangee(personne, rang, dans)} + + {#each COLONNES as colonne, rangColonne (colonne)} + {@const refus = refusDe(personne.id, colonne)} + { + if (evenement.target === evenement.currentTarget) actif = { id: personne.id, colonne: rangColonne }; + }} + onkeydown={(evenement) => surToucheCellule(evenement, personne, rang, rangColonne)} + > + {#if enEdition(personne.id, colonne) && colonne === 'notes'} + + {:else if enEdition(personne.id, colonne)} + surToucheEntree(evenement, rang)} + onblur={surSortie} + /> + {:else if colonne === 'exclu'} + + {t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')} + + {:else} + {personne[colonne] ?? ''} + {/if} + {#if refus !== null} + (refusCellule = null)} /> + {/if} + + {/each} + +
+ + + {#if personne.exclu} + seance.executer('reintegrerParticipant', { id: personne.id })} + /> + {:else} + seance.executer('exclureParticipant', { id: personne.id })} + /> + {/if} + supprimer(personne)} /> +
+ + +{/snippet} + +{#snippet bloc(groupe)} + {@const replie = replies.has(groupe.cle)} + + + + + + {#if !replie} + {#if groupe.sousGroupes === null} + {#each groupe.lignes as personne (personne.id)} + {@render rangee(personne, rangs.get(personne.id), groupe.cle)} + {/each} + {:else} + {#each groupe.sousGroupes as sousGroupe (sousGroupe.cle)} + {@render bloc(sousGroupe)} + {/each} + {/if} + {/if} +{/snippet} +
{t('participants.grille.actions')} - - {#each lignes as personne, rang (personne.id)} - - {#each COLONNES as colonne, rangColonne (colonne)} - {@const refus = refusDe(personne.id, colonne)} - - {/each} - - + {#if groupes === null} + + {#each lignes as personne (personne.id)} + {@render rangee(personne, rangs.get(personne.id), undefined)} + {/each} + + {:else} + {#each groupes as groupe (groupe.cle)} + + {@render bloc(groupe)} + {/each} - + {/if}
{ - if (evenement.target === evenement.currentTarget) actif = { id: personne.id, colonne: rangColonne }; - }} - onkeydown={(evenement) => surToucheCellule(evenement, personne, rang, rangColonne)} - > - {#if enEdition(personne.id, colonne) && colonne === 'notes'} - - {:else if enEdition(personne.id, colonne)} - surToucheEntree(evenement, rang)} - onblur={surSortie} - /> - {:else if colonne === 'exclu'} - - {t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')} - - {:else} - {personne[colonne] ?? ''} - {/if} - {#if refus !== null} - (refusCellule = null)} /> - {/if} - -
- - - {#if personne.exclu} - seance.executer('reintegrerParticipant', { id: personne.id })} - /> - {:else} - seance.executer('exclureParticipant', { id: personne.id })} - /> - {/if} - supprimer(personne)} /> -
-
@@ -411,6 +492,58 @@ font-weight: normal; } + /* L'en-tête d'un groupe : un bouton qui porte son état en texte, le + libellé du groupe en gras et son compte. */ + .groupe button { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--espace-2); + padding: var(--espace-1) var(--espace-2); + border: 1px solid var(--couleur-page-filet); + border-radius: var(--rayon-1); + background-color: var(--couleur-page-fond); + color: var(--couleur-page-texte); + font: inherit; + text-align: left; + cursor: pointer; + } + + /* Le retrait d'un en-tête croît avec le niveau de son groupe, jusqu'au + dernier niveau possible, un par colonne de GROUPEMENTS. */ + .groupe[data-niveau='2'] button { + margin-left: var(--espace-5); + } + + .groupe[data-niveau='3'] button { + margin-left: calc(2 * var(--espace-5)); + } + + .groupe[data-niveau='4'] button { + margin-left: calc(3 * var(--espace-5)); + } + + .groupe[data-niveau='5'] button { + margin-left: calc(4 * var(--espace-5)); + } + + .groupe[data-niveau='6'] button { + margin-left: calc(5 * var(--espace-5)); + } + + .groupe[data-niveau='7'] button { + margin-left: calc(6 * var(--espace-5)); + } + + .groupe[data-niveau='8'] button { + margin-left: calc(7 * var(--espace-5)); + } + + .libelle { + font-weight: bold; + overflow-wrap: anywhere; + } + .commandes { display: flex; flex-wrap: wrap; diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js index 1d5db15..e3a83d3 100644 --- a/src/interface/participants/Participants.navigateur.test.js +++ b/src/interface/participants/Participants.navigateur.test.js @@ -5,7 +5,8 @@ // § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance // collante, la grille au clavier, le tri, le filtre et le compte, // l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche -// et l'itinéraire, l'export et le collage. Chaque épreuve monte App par +// et l'itinéraire, l'export, le collage, et la liste groupée par une +// colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés. Chaque épreuve monte App par // monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque // écriture se relève (trace) et chaque entrée se compte dans le journal. Les // gestes passent par des entrées que Chromium traite comme réelles @@ -19,7 +20,8 @@ import { flushSync, tick } from 'svelte'; import { bilanChangementDefaut } from '../../application/bilans.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { creerTraduction } from '../../application/traduction.js'; -import { apercevoir, decoder, exporterParticipants } from '../../application/csv.js'; +import { apercevoir, cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js'; +import { statuts } from '../../application/places.js'; import { STATUT, nombrePlacesManquantes, normaliser } from '../../moteur/configuration.js'; import { configurationDepuisCharge } from '../../stockage/document.js'; import { lireJournal } from '../../stockage/journal.js'; @@ -30,7 +32,7 @@ import { pointer, touche } from '../../../test/navigateur/entrees.js'; import { visible } from '../../../test/navigateur/rendu.js'; import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url'; import { TABLES_INTERFACE } from '../libelles.js'; -import { COLONNES, cleDeStatut } from './grille.js'; +import { COLONNES, GROUPEMENTS, cheminDe, cleDeGroupement, cleDeStatut, groupesGrille, lignesGrille, lignesVisibles } from './grille.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); @@ -891,3 +893,465 @@ describe('export et collage (§ 10.2)', () => { expect(m.trace.length).toBe(trace); }); }); + +describe('la liste groupée par une colonne ou plusieurs', () => { + // Cinq personnes : deux du Club des Merles sous deux orthographes, deux + // du Cercle des Pinsons dont une exclue, une sans appartenance. + const CINQ = [ + { nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB, titre: 'hôtesse' }, + { nom: 'Pervenche', prenom: 'Théo', appartenance: 'CLUB DES MERLES' }, + { nom: 'Lacasse', prenom: 'Ondine' }, + { nom: 'Grisaille', appartenance: 'Cercle des Pinsons', exclu: true }, + { nom: 'Brindelle', appartenance: 'Cercle des Pinsons' }, + ]; + const PINSONS = 'texte:cercle des pinsons'; + const MERLES = 'texte:club des merles'; + + // Le sélecteur du niveau donné, 1 pour le plus large, et tous ceux + // affichés. + const selecteur = (m, niveau = 1) => section(m).querySelector(`[data-champ="grouper"][data-niveau="${niveau}"]`); + const selecteurs = (m) => [...section(m).querySelectorAll('[data-champ="grouper"]')]; + // Les en-têtes de groupe affichés, de tout niveau, dans l'ordre du rendu ; + // l'en-tête d'un groupe porte sa clé, chaque ligne celle du plus étroit + // groupe qui la compte. + const groupes = (m) => [...grille(m).querySelectorAll('[data-groupe]')]; + const groupe = (m, cle) => grille(m).querySelector(`[data-groupe="${cle}"]`); + const bascule = (m, cle) => groupe(m, cle).querySelector('[data-commande="basculerGroupe"]'); + const idsDuGroupe = (m, cle) => + lignes(m) + .filter(({ dataset: { groupeLigne } }) => groupeLigne === cle || groupeLigne.startsWith(`${cle}/`)) + .map((l) => Number(l.dataset.participant)); + const ouverts = (m) => groupes(m).map((g) => bascule(m, g.dataset.groupe).getAttribute('aria-expanded')); + // Tous les groupes d'un arbre de groupesGrille, chacun avant ses + // sous-groupes : l'ordre des en-têtes déplié. + const aplatir = (arbre) => arbre.flatMap((g) => [g, ...(g.sousGroupes === null ? [] : aplatir(g.sousGroupes))]); + // Ce que montre l'en-tête d'un groupe : son libellé et son compte. + const entete = (m, cle) => ({ + libelle: groupe(m, cle).querySelector('[data-groupe-libelle]').textContent, + compte: groupe(m, cle).querySelector('[data-groupe-compte]').textContent, + }); + // Le libellé et le compte qu'un groupe de groupesGrille doit afficher. + const enteteAttendu = ({ libelle, cleLibelle, compte }) => ({ + libelle: libelle ?? t(cleLibelle), + compte: t('participants.groupe.compte', { compte }), + }); + + // Choisit la colonne d'un niveau du groupement au clavier seul : le + // sélecteur de ce niveau prend le focus, puis les flèches le mènent à + // l'option voulue. + async function grouperPar(m, colonne, niveau = 1) { + const choix = selecteur(m, niveau); + const valeurs = [...choix.options].map((option) => option.value); + const cible = valeurs.indexOf(colonne); + expect(cible, colonne).toBeGreaterThanOrEqual(0); + choix.focus(); + for (let essai = 0; essai < valeurs.length && choix.selectedIndex !== cible; essai += 1) { + await touche(choix.selectedIndex < cible ? 'ArrowDown' : 'ArrowUp'); + await rendu(); + } + expect(choix.value).toBe(colonne); + await rendu(); + } + + // Le groupement par appartenance puis par statut, Ombrelle ancrée par une + // réservation : les autres présentes sont mobiles. + async function appartenancePuisStatut() { + const m = await evenementOuvert({ tables: 1, personnes: CINQ }); + const charge = m.seance.etat().charge; + await m.seance.executer('reserver', { participant: idDe(m, 'Ombrelle'), table: charge.tables[0].id, siege: null, portee: 'tous', tour: null }); + await attendreSeance(m); + await grouperPar(m, 'appartenance', 1); + await grouperPar(m, 'statut', 2); + return m; + } + + // Le journal, l'état et la trace d'écriture, à comparer après des gestes + // de regard. + const empreinte = (m) => ({ journal: texteJournal(m), etat: texteEtat(m), trace: m.trace.length }); + + test('le sélecteur offre « Aucun » puis chaque colonne groupable ; sans groupement, aucun en-tête de groupe', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + const options = [...selecteur(m).options]; + expect(options.map((o) => o.value)).toEqual(['', ...GROUPEMENTS]); + expect(options.map((o) => o.textContent)).toEqual([t('participants.groupe.aucun'), ...GROUPEMENTS.map((c) => t(cleDeGroupement(c)))]); + expect(selecteur(m).value).toBe(''); + expect(selecteurs(m)).toHaveLength(1); + expect(groupes(m)).toHaveLength(0); + expect(lignes(m)).toHaveLength(5); + expect(section(m).querySelector('[data-commande="toutReplier"]')).toBeNull(); + }); + + test('chaque colonne choisie ouvre le niveau suivant, aux colonnes pas encore prises ; « Aucun » retire un niveau et les suivants', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + await grouperPar(m, 'appartenance', 1); + expect(selecteurs(m)).toHaveLength(2); + expect(selecteur(m, 2).value).toBe(''); + const deuxieme = [...selecteur(m, 2).options]; + expect(deuxieme.map((o) => o.value)).toEqual(['', ...GROUPEMENTS.filter((c) => c !== 'appartenance')]); + expect(deuxieme[0].textContent).toBe(t('participants.groupe.aucunSousGroupe')); + // Chaque sélecteur a son étiquette, distincte des autres. + const etiquettes = selecteurs(m).map((choix) => choix.labels[0]?.textContent); + expect(etiquettes).toEqual([t('participants.groupe.grouperPar'), t('participants.groupe.puisPar', { niveau: 2 })]); + + await grouperPar(m, 'statut', 2); + expect(selecteurs(m).map((choix) => choix.value)).toEqual(['appartenance', 'statut', '']); + expect([...selecteur(m, 3).options].map((o) => o.value)).toEqual(['', ...GROUPEMENTS.filter((c) => c !== 'appartenance' && c !== 'statut')]); + expect(new Set(groupes(m).map((g) => g.dataset.niveau))).toEqual(new Set(['1', '2'])); + + // La colonne d'un niveau prise plus haut quitte le niveau plus bas. + await grouperPar(m, 'statut', 1); + expect(selecteurs(m).map((choix) => choix.value)).toEqual(['statut', '']); + await grouperPar(m, 'nom', 2); + await grouperPar(m, 'prenom', 3); + expect(selecteurs(m).map((choix) => choix.value)).toEqual(['statut', 'nom', 'prenom', '']); + await grouperPar(m, '', 2); + expect(selecteurs(m).map((choix) => choix.value)).toEqual(['statut', '']); + expect(new Set(groupes(m).map((g) => g.dataset.niveau))).toEqual(new Set(['1'])); + await grouperPar(m, '', 1); + expect(selecteurs(m).map((choix) => choix.value)).toEqual(['']); + expect(groupes(m)).toHaveLength(0); + expect(lignes(m)).toHaveLength(5); + + // Toutes les colonnes prises : aucun sélecteur de plus. + for (const [rang, colonne] of GROUPEMENTS.entries()) await grouperPar(m, colonne, rang + 1); + expect(selecteurs(m)).toHaveLength(GROUPEMENTS.length); + }); + + test('par appartenance : un en-tête par groupe, son libellé et son compte, ses lignes dessous ; puis chaque colonne groupable suit groupesGrille ; rien ne s’écrit', async () => { + const m = await evenementOuvert({ tables: 1, personnes: CINQ }); + const charge = m.seance.etat().charge; + await m.seance.executer('reserver', { participant: idDe(m, 'Ombrelle'), table: charge.tables[0].id, siege: null, portee: 'tous', tour: null }); + await attendreSeance(m); + const avant = empreinte(m); + + await grouperPar(m, 'appartenance'); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']); + expect(entete(m, PINSONS)).toEqual({ libelle: 'Cercle des Pinsons', compte: t('participants.groupe.compte', { compte: 2 }) }); + expect(entete(m, MERLES)).toEqual({ libelle: CLUB, compte: t('participants.groupe.compte', { compte: 2 }) }); + expect(entete(m, 'vide')).toEqual({ libelle: t('participants.groupe.vide'), compte: t('participants.groupe.compte', { compte: 1 }) }); + expect(idsDuGroupe(m, PINSONS)).toEqual([idDe(m, 'Grisaille'), idDe(m, 'Brindelle')]); + expect(idsDuGroupe(m, MERLES)).toEqual([idDe(m, 'Ombrelle'), idDe(m, 'Pervenche')]); + expect(idsDuGroupe(m, 'vide')).toEqual([idDe(m, 'Lacasse')]); + // L'en-tête précède les lignes de son groupe, dans le rendu. + const premiere = ligne(m, idDe(m, 'Grisaille')).getBoundingClientRect(); + expect(bascule(m, PINSONS).getBoundingClientRect().bottom).toBeLessThanOrEqual(premiere.top + 1); + + await grouperPar(m, 'statut'); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(['statut:ancre', 'statut:mobile', 'statut:exclue']); + expect(entete(m, 'statut:ancre').libelle).toBe(t('participants.statut.ancre')); + expect(idsDuGroupe(m, 'statut:ancre')).toEqual([idDe(m, 'Ombrelle')]); + expect(idsDuGroupe(m, 'statut:exclue')).toEqual([idDe(m, 'Grisaille')]); + + expect(GROUPEMENTS.length).toBeGreaterThan(0); + const lignesAttendues = lignesGrille(participants(m), { tri: null, filtre: '' }, cleNormalisee); + for (const colonne of GROUPEMENTS) { + await grouperPar(m, colonne); + const attendus = groupesGrille(lignesAttendues, [colonne], { tri: null, cleNormalisee, statuts: statuts(m.seance.etat().charge).parId }); + expect(attendus.length, colonne).toBeGreaterThan(0); + expect(groupes(m).map((g) => g.dataset.groupe), colonne).toEqual(attendus.map(({ cle }) => cle)); + for (const attendu of attendus) { + expect(entete(m, attendu.cle), `${colonne} ${attendu.cle}`).toEqual(enteteAttendu(attendu)); + expect(idsDuGroupe(m, attendu.cle), `${colonne} ${attendu.cle}`).toEqual(attendu.lignes.map(({ id }) => id)); + expect(bascule(m, attendu.cle).getAttribute('aria-expanded')).toBe('true'); + } + } + await grouperPar(m, ''); + expect(groupes(m)).toHaveLength(0); + expect(lignes(m)).toHaveLength(5); + await attendreSeance(m); + expect(empreinte(m)).toEqual(avant); + }); + + test('replier masque exactement les lignes du groupe, déplier les rend ; Tout replier, Tout déplier ; l’état se lit en texte et dans aria-expanded ; rien ne s’écrit, en écriture comme en lecture', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + for (const mode of ['ecriture', 'lecture']) { + if (mode === 'lecture') { + await m.seance.passerEnLecture(); + await rendu(); + } + const avant = empreinte(m); + await grouperPar(m, 'appartenance'); + const tous = idsAffiches(m); + expect(tous, mode).toHaveLength(5); + const merles = idsDuGroupe(m, MERLES); + expect(merles, mode).toHaveLength(2); + const texteOuvert = bascule(m, MERLES).querySelector('[data-groupe-etat]').textContent; + + await cliquer(bascule(m, MERLES)); + expect(bascule(m, MERLES).getAttribute('aria-expanded'), mode).toBe('false'); + expect(idsDuGroupe(m, MERLES), mode).toEqual([]); + expect(idsAffiches(m), mode).toEqual(tous.filter((id) => !merles.includes(id))); + expect(entete(m, MERLES).compte, mode).toBe(t('participants.groupe.compte', { compte: 2 })); + // Replié et ouvert se distinguent par le texte, pas seulement par la couleur. + expect(bascule(m, MERLES).querySelector('[data-groupe-etat]').textContent, mode).not.toBe(texteOuvert); + expect(bascule(m, PINSONS).getAttribute('aria-expanded'), mode).toBe('true'); + + await cliquer(bascule(m, MERLES)); + expect(bascule(m, MERLES).getAttribute('aria-expanded'), mode).toBe('true'); + expect(idsAffiches(m), mode).toEqual(tous); + + // Au clavier : Entrée sur le bouton replie. + bascule(m, PINSONS).focus(); + await touche('Enter'); + await rendu(); + expect(bascule(m, PINSONS).getAttribute('aria-expanded'), mode).toBe('false'); + expect(idsDuGroupe(m, PINSONS), mode).toEqual([]); + await touche(' '); + await rendu(); + expect(bascule(m, PINSONS).getAttribute('aria-expanded'), mode).toBe('true'); + + await cliquer(section(m).querySelector('[data-commande="toutReplier"]')); + expect(lignes(m), mode).toHaveLength(0); + expect(groupes(m).map((g) => bascule(m, g.dataset.groupe).getAttribute('aria-expanded')), mode).toEqual(['false', 'false', 'false']); + await cliquer(section(m).querySelector('[data-commande="toutDeplier"]')); + expect(idsAffiches(m), mode).toEqual(tous); + expect(groupes(m).map((g) => bascule(m, g.dataset.groupe).getAttribute('aria-expanded')), mode).toEqual(['true', 'true', 'true']); + + await attendreSeance(m); + expect(empreinte(m), mode).toEqual(avant); + } + }); + + test('le filtre passe d’abord : un groupe sans ligne disparaît, chaque compte est celui des lignes montrées ; trié par la colonne groupée, les groupes suivent le sens', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + await grouperPar(m, 'appartenance'); + await saisir(section(m).querySelector('[data-champ="filtre"]'), 'pinsons'); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS]); + expect(entete(m, PINSONS).compte).toBe(t('participants.groupe.compte', { compte: 2 })); + await saisir(section(m).querySelector('[data-champ="filtre"]'), 'ondine'); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(['vide']); + expect(entete(m, 'vide').compte).toBe(t('participants.groupe.compte', { compte: 1 })); + await saisir(section(m).querySelector('[data-champ="filtre"]'), 'iris'); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([MERLES]); + expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 1 })); + await saisir(section(m).querySelector('[data-champ="filtre"]'), ''); + + await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="appartenance"]')); + await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="appartenance"]')); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([MERLES, PINSONS, 'vide']); + // Trié par nom décroissant : groupes croissants, lignes au tri dans chacun. + await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]')); + await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]')); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']); + expect(idsDuGroupe(m, PINSONS)).toEqual([idDe(m, 'Grisaille'), idDe(m, 'Brindelle')]); + expect(idsDuGroupe(m, MERLES)).toEqual([idDe(m, 'Pervenche'), idDe(m, 'Ombrelle')]); + }); + + test('au clavier, les flèches et Tab passent d’une ligne visible à la suivante, par-dessus les en-têtes et les groupes repliés', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + await grouperPar(m, 'appartenance'); + await cliquer(bascule(m, MERLES)); + const [grisaille, brindelle, lacasse] = ['Grisaille', 'Brindelle', 'Lacasse'].map((nom) => idDe(m, nom)); + expect(idsAffiches(m)).toEqual([grisaille, brindelle, lacasse]); + + const premiere = grille(m).querySelector('td[tabindex="0"]'); + expect(premiere).toBe(cellule(m, grisaille, COLONNES[0])); + premiere.focus(); + await touche('ArrowDown'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, brindelle, COLONNES[0])); + await touche('ArrowDown'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, lacasse, COLONNES[0])); + await touche('ArrowDown'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, lacasse, COLONNES[0])); + await touche('ArrowUp'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, brindelle, COLONNES[0])); + + // Tab depuis la dernière cellule de Brindelle mène à la première de Lacasse. + cellule(m, brindelle, COLONNES.at(-1)).focus(); + await touche('Tab'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, lacasse, COLONNES[0])); + // Une seule cellule de la grille dans l'ordre de tabulation. + expect(grille(m).querySelectorAll('td[tabindex="0"]')).toHaveLength(1); + }); + + test('une saisie qui change la valeur groupée mène la ligne à son nouveau groupe, la cellule garde le focus ; un groupe replié qui la reçoit se déplie ; une entrée par saisie', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + await grouperPar(m, 'appartenance'); + const lacasse = idDe(m, 'Lacasse'); + const brindelle = idDe(m, 'Brindelle'); + const avant = entrees(m).length; + + cellule(m, lacasse, 'appartenance').focus(); + await touche('Enter'); + await rendu(); + await saisir(cellule(m, lacasse, 'appartenance').querySelector('input'), 'Cercle des Pinsons'); + await touche('Enter'); + await attendreSeance(m); + expect(entrees(m)).toHaveLength(avant + 1); + expect(idsDuGroupe(m, PINSONS)).toContain(lacasse); + expect(groupe(m, 'vide')).toBeNull(); + expect(document.activeElement).toBe(cellule(m, lacasse, 'appartenance')); + expect(entete(m, PINSONS).compte).toBe(t('participants.groupe.compte', { compte: 3 })); + + // Vers un groupe replié : il se déplie, et la cellule garde le focus. + await cliquer(bascule(m, MERLES)); + expect(idsDuGroupe(m, MERLES)).toEqual([]); + cellule(m, brindelle, 'appartenance').focus(); + await touche('Enter'); + await rendu(); + await saisir(cellule(m, brindelle, 'appartenance').querySelector('input'), CLUB); + await touche('Enter'); + await attendreSeance(m); + expect(entrees(m)).toHaveLength(avant + 2); + expect(bascule(m, MERLES).getAttribute('aria-expanded')).toBe('true'); + expect(idsDuGroupe(m, MERLES)).toContain(brindelle); + expect(document.activeElement).toBe(cellule(m, brindelle, 'appartenance')); + }); + + test('changer de groupement déplie tout : d’une colonne à une autre, ou à l’ajout d’un niveau', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + await grouperPar(m, 'appartenance'); + await cliquer(bascule(m, 'vide')); + expect(bascule(m, 'vide').getAttribute('aria-expanded')).toBe('false'); + expect(idsAffiches(m)).toHaveLength(4); + + // Le prénom a lui aussi un groupe « vide ». + await grouperPar(m, 'prenom'); + expect(groupe(m, 'vide')).not.toBeNull(); + expect(ouverts(m).length).toBeGreaterThan(0); + expect(ouverts(m).every((etat) => etat === 'true')).toBe(true); + expect(idsAffiches(m)).toHaveLength(5); + + await cliquer(bascule(m, 'vide')); + expect(idsAffiches(m)).toHaveLength(3); + await grouperPar(m, 'statut', 2); + expect(ouverts(m).every((etat) => etat === 'true')).toBe(true); + expect(idsAffiches(m)).toHaveLength(5); + }); + + test('validée par la sortie du champ, le focus posé ailleurs y reste ; le groupe replié qui reçoit la ligne le reste', async () => { + const m = await evenementOuvert({ personnes: CINQ }); + await grouperPar(m, 'appartenance'); + const lacasse = idDe(m, 'Lacasse'); + const ombrelle = idDe(m, 'Ombrelle'); + const filtre = section(m).querySelector('[data-champ="filtre"]'); + const avant = entrees(m).length; + + cellule(m, lacasse, 'appartenance').focus(); + await touche('Enter'); + await rendu(); + await saisir(cellule(m, lacasse, 'appartenance').querySelector('input'), 'Cercle des Pinsons'); + await cliquer(filtre); + await attendreSeance(m); + expect(entrees(m)).toHaveLength(avant + 1); + expect(idsDuGroupe(m, PINSONS)).toContain(lacasse); + expect(document.activeElement).toBe(filtre); + + // Le groupe qui reçoit la ligne, replié avant la saisie, le reste. + await cliquer(bascule(m, PINSONS)); + cellule(m, ombrelle, 'appartenance').focus(); + await touche('Enter'); + await rendu(); + await saisir(cellule(m, ombrelle, 'appartenance').querySelector('input'), 'Cercle des Pinsons'); + await cliquer(filtre); + await attendreSeance(m); + expect(entrees(m)).toHaveLength(avant + 2); + expect(bascule(m, PINSONS).getAttribute('aria-expanded')).toBe('false'); + expect(entete(m, PINSONS).compte).toBe(t('participants.groupe.compte', { compte: 4 })); + expect(idsAffiches(m)).not.toContain(ombrelle); + expect(document.activeElement).toBe(filtre); + }); + + test('appartenance puis statut : en-têtes imbriqués, comptes, repli du sous-groupe seul ou du parent entier ; rien ne s’écrit', async () => { + const m = await appartenancePuisStatut(); + const avant = empreinte(m); + const [ombrelle, pervenche] = ['Ombrelle', 'Pervenche'].map((nom) => idDe(m, nom)); + const attendus = groupesGrille(lignesGrille(participants(m), { tri: null, filtre: '' }, cleNormalisee), ['appartenance', 'statut'], { + tri: null, + cleNormalisee, + statuts: statuts(m.seance.etat().charge).parId, + }); + const tous = aplatir(attendus); + expect(tous.some(({ niveau }) => niveau === 2)).toBe(true); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(tous.map(({ cle }) => cle)); + expect(groupes(m).map((g) => Number(g.dataset.niveau))).toEqual(tous.map(({ niveau }) => niveau)); + for (const attendu of tous) { + expect(entete(m, attendu.cle), attendu.cle).toEqual(enteteAttendu(attendu)); + expect(idsDuGroupe(m, attendu.cle), attendu.cle).toEqual(lignesVisibles([attendu], new Set()).map(({ id }) => id)); + } + expect(idsAffiches(m)).toEqual(lignesVisibles(attendus, new Set()).map(({ id }) => id)); + expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 2 })); + expect(idsDuGroupe(m, `${MERLES}/statut:ancre`)).toEqual([ombrelle]); + expect(idsDuGroupe(m, `${MERLES}/statut:mobile`)).toEqual([pervenche]); + // Un en-tête de sous-groupe est en retrait de celui de son parent. + expect(bascule(m, `${MERLES}/statut:ancre`).getBoundingClientRect().left).toBeGreaterThan(bascule(m, MERLES).getBoundingClientRect().left); + + await cliquer(bascule(m, `${MERLES}/statut:mobile`)); + expect(bascule(m, `${MERLES}/statut:mobile`).getAttribute('aria-expanded')).toBe('false'); + expect(idsDuGroupe(m, MERLES)).toEqual([ombrelle]); + expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 2 })); + expect(entete(m, `${MERLES}/statut:mobile`).compte).toBe(t('participants.groupe.compte', { compte: 1 })); + expect(bascule(m, MERLES).getAttribute('aria-expanded')).toBe('true'); + + await cliquer(bascule(m, MERLES)); + expect(groupe(m, `${MERLES}/statut:ancre`)).toBeNull(); + expect(groupe(m, `${MERLES}/statut:mobile`)).toBeNull(); + expect(idsDuGroupe(m, MERLES)).toEqual([]); + expect(idsAffiches(m)).toEqual(lignesVisibles(attendus, new Set([MERLES])).map(({ id }) => id)); + // Déplié, le parent rend ses sous-groupes dans l'état où il les a laissés. + await cliquer(bascule(m, MERLES)); + expect(bascule(m, `${MERLES}/statut:mobile`).getAttribute('aria-expanded')).toBe('false'); + expect(bascule(m, `${MERLES}/statut:ancre`).getAttribute('aria-expanded')).toBe('true'); + + // Tout replier replie le premier niveau ; Tout déplier déplie tous les niveaux. + await cliquer(section(m).querySelector('[data-commande="toutReplier"]')); + expect(lignes(m)).toHaveLength(0); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(attendus.map(({ cle }) => cle)); + expect(ouverts(m).every((etat) => etat === 'false')).toBe(true); + // Un parent déplié après Tout replier montre ses sous-groupes dépliés. + await cliquer(bascule(m, MERLES)); + expect(ouverts(m).filter((etat) => etat === 'true')).toHaveLength(3); + expect(idsDuGroupe(m, MERLES)).toEqual([ombrelle, pervenche]); + await cliquer(section(m).querySelector('[data-commande="toutDeplier"]')); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(tous.map(({ cle }) => cle)); + expect(ouverts(m).every((etat) => etat === 'true')).toBe(true); + expect(idsAffiches(m)).toEqual(lignesVisibles(attendus, new Set()).map(({ id }) => id)); + + await attendreSeance(m); + expect(empreinte(m)).toEqual(avant); + }); + + test('imbriqués : les flèches sautent tout en-tête ; une ligne menée sous un parent et un sous-groupe repliés les déplie', async () => { + const m = await appartenancePuisStatut(); + const [ombrelle, pervenche, lacasse, grisaille, brindelle] = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Brindelle'].map((nom) => idDe(m, nom)); + await cliquer(bascule(m, `${MERLES}/statut:ancre`)); + expect(idsAffiches(m)).toEqual([brindelle, grisaille, pervenche, lacasse]); + cellule(m, grisaille, COLONNES[0]).focus(); + await touche('ArrowDown'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, pervenche, COLONNES[0])); + await touche('ArrowUp'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, grisaille, COLONNES[0])); + await cliquer(section(m).querySelector('[data-commande="toutDeplier"]')); + expect(idsAffiches(m)).toContain(ombrelle); + + // Lacasse rejoint les Merles, mobile comme Pervenche : le sous-groupe + // et son parent, tous deux repliés, se déplient. + const destination = `${MERLES}/statut:mobile`; + expect(cheminDe(groupesGrille(lignesGrille(participants(m), { tri: null, filtre: '' }, cleNormalisee), ['appartenance', 'statut'], { + tri: null, + cleNormalisee, + statuts: statuts(m.seance.etat().charge).parId, + }), pervenche)).toEqual([MERLES, destination]); + await cliquer(bascule(m, destination)); + await cliquer(bascule(m, MERLES)); + const avant = entrees(m).length; + cellule(m, lacasse, 'appartenance').focus(); + await touche('Enter'); + await rendu(); + await saisir(cellule(m, lacasse, 'appartenance').querySelector('input'), CLUB); + await touche('Enter'); + await attendreSeance(m); + expect(entrees(m)).toHaveLength(avant + 1); + expect(bascule(m, MERLES).getAttribute('aria-expanded')).toBe('true'); + expect(bascule(m, destination).getAttribute('aria-expanded')).toBe('true'); + expect(idsDuGroupe(m, destination)).toEqual([pervenche, lacasse]); + expect(document.activeElement).toBe(cellule(m, lacasse, 'appartenance')); + }); +}); diff --git a/src/interface/participants/SectionParticipants.svelte b/src/interface/participants/SectionParticipants.svelte index c325107..c0c09ed 100644 --- a/src/interface/participants/SectionParticipants.svelte +++ b/src/interface/participants/SectionParticipants.svelte @@ -9,6 +9,19 @@ // section : regarder, trier et filtrer n'écrivent rien, en écriture comme // en lecture. La fiche ouverte est celle de l'état de navigation, ui.fiche. // + // « Grouper par » groupe les lignes par une ou plusieurs colonnes de + // GROUPEMENTS, la plus large d'abord, en groupes imbriqués de + // groupesGrille, sur les lignes déjà filtrées et triées. Un sélecteur par + // niveau de niveauxAffiches : un par colonne choisie, puis celui du + // niveau suivant tant qu'une colonne reste libre ; chacun offre + // colonnesOffertes, et son choix passe par choisirGroupement — la valeur + // vide retire son niveau et ceux d'après. Le statut se lit dans statuts + // (src/application/places.js), calculé seulement quand il groupe. + // « Tout replier » replie les groupes du premier niveau, « Tout déplier » + // les groupes de tout niveau. Le groupement et les groupes repliés vivent + // dans la section, le temps qu'elle est ouverte : changer de groupement + // déplie tout, et rien ne s'écrit. + // // Supprimer une personne qui porte des réservations ouvre la fenêtre à // confirmer, qui les nomme ; sans réservation, ou quand la séance refuse // déjà tout geste modifiant, la commande part et la séance répond. L'export @@ -22,7 +35,17 @@ import Fiche from './Fiche.svelte'; import Formulaire from './Formulaire.svelte'; import Grille from './Grille.svelte'; - import { lignesGrille, nomExport, reservationsDe } from './grille.js'; + import { statuts } from '../../application/places.js'; + import { + choisirGroupement, + cleDeGroupement, + colonnesOffertes, + groupesGrille, + lignesGrille, + niveauxAffiches, + nomExport, + reservationsDe, + } from './grille.js'; const t = getContext('t'); const seance = getContext('seance'); @@ -32,14 +55,26 @@ let tri = $state.raw(null); let filtre = $state(''); + // Les colonnes du groupement, la plus large d'abord, vide sans + // groupement, et les clés des groupes repliés. + let groupement = $state.raw([]); + let replies = $state.raw(new Set()); // La suppression qui attend sa confirmation : {participant, reservations}. let aSupprimer = $state.raw(null); const charge = $derived($seance.charge); const participants = $derived(charge?.participants ?? []); const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee)); + const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null); + const groupes = $derived(groupement.length === 0 ? null : groupesGrille(lignes, groupement, { tri, cleNormalisee, statuts: parStatut })); + const niveaux = $derived(niveauxAffiches(groupement)); const personneFiche = $derived($ui.fiche === null ? null : (participants.find(({ id }) => id === $ui.fiche) ?? null)); + function choisirNiveau(rang, colonne) { + groupement = choisirGroupement(groupement, rang, colonne); + replies = new Set(); + } + async function exporter() { const { evenement, charge: courante } = seance.etat(); await facade.enregistrerSous(nomExport(evenement.base), exporterParticipants(courante.participants)); @@ -70,13 +105,40 @@ + {#each niveaux as rang (rang)} +
+ + +
+ {/each} + {#if groupes !== null} + + + {/if} {t('participants.grille.compte', { total: participants.length, affiches: lignes.length })}
- + {#if participants.length === 0}

{t('participants.grille.vide')}

{:else if lignes.length === 0} @@ -124,7 +186,8 @@ gap: var(--espace-1); } - input { + input, + select { padding: var(--espace-1) var(--espace-2); border: 1px solid var(--couleur-page-filet); border-radius: var(--rayon-1); @@ -134,7 +197,20 @@ font-size: var(--taille-texte); } - input:focus-visible { + .outils > button { + padding: var(--espace-1) var(--espace-3); + border: 1px solid var(--couleur-page-filet); + border-radius: var(--rayon-1); + background-color: var(--couleur-page-fond); + color: var(--couleur-page-texte); + font: inherit; + font-size: var(--taille-texte); + cursor: pointer; + } + + input:focus-visible, + select:focus-visible, + .outils > button:focus-visible { outline: 2px solid var(--couleur-focus); outline-offset: 1px; } diff --git a/src/interface/participants/grille.js b/src/interface/participants/grille.js index bbb64d1..901976f 100644 --- a/src/interface/participants/grille.js +++ b/src/interface/participants/grille.js @@ -4,8 +4,10 @@ // Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2), que // câblent SectionParticipants, Grille, Formulaire et Fiche : les colonnes de // la grille, ses lignes triées et filtrées, la cellule qu'atteint une -// touche, l'orthographe d'appartenance à reprendre, l'itinéraire d'une -// personne dans le retenu et ses réservations. Aucune fonction ne modifie ce +// touche, les groupes, imbriqués, de la liste groupée par une colonne ou +// plusieurs et les lignes que laissent voir les groupes repliés, +// l'orthographe d'appartenance à reprendre, l'itinéraire d'une personne +// dans le retenu et ses réservations. Aucune fonction ne modifie ce // qu'elle reçoit ; la clé normalisée vient de l'appelant, qui la tient de // src/application/csv.js. @@ -115,6 +117,252 @@ export function lignesGrille(participants, { tri, filtre }, cleNormalisee) { return retenues.sort(comparer); } +/** Les colonnes par lesquelles la liste se groupe : celles de la grille, + * dans leur ordre, puis le statut calculé (§ 4.2), qui n'est pas une + * colonne. */ +export const GROUPEMENTS = Object.freeze([...COLONNES, 'statut']); + +/** + * La clé du libellé d'une colonne de GROUPEMENTS : celle de sa colonne de + * la grille, ou celle du statut. + * + * @param {string} colonne + * @returns {string} + * @throws {RangeError} hors de GROUPEMENTS + */ +export function cleDeGroupement(colonne) { + return colonne === 'statut' ? 'participants.groupe.statut' : cleDeColonne(colonne); +} + +/** + * Le groupement après le choix de colonne au niveau rang, compté depuis 0 : + * la liste des colonnes de GROUPEMENTS, la plus large d'abord. La valeur + * vide retire ce niveau et tous ceux d'après ; une colonne remplace celle + * du niveau, ou l'ajoute au premier niveau libre, et les niveaux suivants + * restent, hors celui qui la portait déjà. Rend une nouvelle liste. + * + * @param {Array} groupement + * @param {number} rang de 0 au nombre de niveaux + * @param {string} colonne '' ou une colonne de colonnesOffertes(groupement, rang) + * @returns {Array} + * @throws {RangeError} un rang au-delà du premier niveau libre, une colonne + * hors de celles offertes à ce rang + */ +export function choisirGroupement(groupement, rang, colonne) { + if (!Number.isInteger(rang) || rang < 0 || rang > groupement.length) throw new RangeError(`niveau inconnu : ${rang}`); + const plusLarges = groupement.slice(0, rang); + if (colonne === '') return plusLarges; + if (!colonnesOffertes(groupement, rang).includes(colonne)) throw new RangeError(`groupement inconnu : ${JSON.stringify(colonne)}`); + return [...plusLarges, colonne, ...groupement.slice(rang + 1).filter((suivante) => suivante !== colonne)]; +} + +/** + * Les rangs, comptés depuis 0, des niveaux dont le groupement montre le + * sélecteur : un par colonne choisie, puis celui du niveau suivant tant + * qu'une colonne de GROUPEMENTS reste libre. + * + * @param {Array} groupement + * @returns {Array} + */ +export function niveauxAffiches(groupement) { + const nombre = groupement.length < GROUPEMENTS.length ? groupement.length + 1 : groupement.length; + return Array.from({ length: nombre }, (_, rang) => rang); +} + +/** + * Les colonnes que le niveau rang du groupement peut prendre : celles de + * GROUPEMENTS, dans leur ordre, hors celles des niveaux plus larges. + * + * @param {Array} groupement + * @param {number} rang compté depuis 0 + * @returns {Array} + */ +export function colonnesOffertes(groupement, rang) { + const prises = groupement.slice(0, rang); + return GROUPEMENTS.filter((colonne) => !prises.includes(colonne)); +} + +// Les groupes d'un groupement par statut, dans leur ordre, et la clé du +// libellé de chacun : les trois statuts calculés, une personne exclue, qui +// n'en a aucun, puis une personne présente que le calcul ne nomme pas — +// celui d'une configuration incohérente. +const GROUPES_STATUT = new Map([ + ['ancre', 'participants.statut.ancre'], + ['partiel', 'participants.statut.partiel'], + ['mobile', 'participants.statut.mobile'], + ['exclue', 'participants.grille.exclue'], + ['inconnu', 'participants.groupe.statutInconnu'], +]); +const RANGS_STATUT = new Map([...GROUPES_STATUT.keys()].map((statut, rang) => [statut, rang])); + +// Un segment de la clé d'un groupe : « % » et « / » s'y échappent, si +// bien que « / » ne sépare jamais que deux niveaux. +const segment = (texte) => texte.replaceAll('%', '%25').replaceAll('/', '%2F'); + +/** + * Les groupes de la liste groupée par une ou plusieurs colonnes, imbriqués + * dans l'ordre de colonnes : un groupe par valeur distincte de la première + * parmi les lignes reçues, qui sont celles de lignesGrille — le filtre est + * déjà passé, et un groupe n'existe que par ses lignes —, puis, dans chaque + * groupe, un sous-groupe par valeur distincte de la colonne suivante, et + * ainsi de suite. Chaque groupe garde ses lignes dans l'ordre reçu, celui + * du tri. + * + * Une colonne de texte groupe par la clé normalisée, comme le tri la lit : + * le groupe se nomme par l'orthographe du plus petit identifiant qu'il + * compte, que ni le tri ni l'ordre reçu ne changent. Un champ null forme le + * groupe vide, après toute valeur dans les deux sens. L'exclusion groupe + * les présentes avant les exclues. Le statut groupe par statuts.get(id), + * dans l'ordre de GROUPES_STATUT : une exclue, absente des statuts + * calculés, dans son groupe ; une présente qui y manque, dans le groupe non + * calculé. + * + * Les groupes d'un niveau s'ordonnent sur leur valeur dans le sens du tri + * quand le tri porte sur la colonne de ce niveau, croissants sinon ; l'ordre + * est total, donc le même quel que soit l'ordre de découverte des valeurs. + * + * Un groupe rend sa clé, unique parmi tous les groupes de tous les niveaux + * et stable tant que les valeurs qui le désignent ne changent pas : celle + * de son parent, « / », puis le segment de sa valeur — 'texte:', où « % » et « / » s'échappent en %25 et %2F, 'vide', + * 'exclu:non', 'exclu:oui', 'statut:' ; au premier niveau, le seul + * segment. Il rend aussi son niveau, de 1 au nombre de colonnes, sa + * colonne, son libellé, qui est soit une donnée (libelle), soit la clé d'un + * texte de la table de traduction (cleLibelle), l'autre valant null, le + * nombre de ses lignes, ses lignes, et ses sous-groupes : null au dernier + * niveau, la liste des groupes de la colonne suivante sinon. + * + * @param {Array} lignes les lignes de la grille, dans leur ordre + * @param {Array} colonnes des colonnes de GROUPEMENTS, distinctes, + * au moins une, la plus large d'abord + * @param {{tri: null|{colonne: string, sens: 'croissant'|'decroissant'}, + * cleNormalisee: (texte: string) => string, + * statuts: Map|null}} options + * @returns {Array<{cle: string, niveau: number, colonne: string, libelle: string|null, + * cleLibelle: string|null, compte: number, lignes: Array, sousGroupes: Array|null}>} + * @throws {RangeError} colonnes qui n'est pas une liste, vide, répétée ou + * hors de GROUPEMENTS + */ +export function groupesGrille(lignes, colonnes, { tri, cleNormalisee, statuts }) { + if ( + !Array.isArray(colonnes) || + colonnes.length === 0 || + new Set(colonnes).size !== colonnes.length || + !colonnes.every((colonne) => GROUPEMENTS.includes(colonne)) + ) { + throw new RangeError(`groupement inconnu : ${JSON.stringify(colonnes)}`); + } + return grouperNiveau(lignes, colonnes, 1, null, { tri, cleNormalisee, statuts }); +} + +// Les groupes des lignes sur colonnes[niveau - 1], chacun sous-groupé par +// les colonnes suivantes ; parent est la clé du groupe qui les contient, +// null au premier niveau. +function grouperNiveau(lignes, colonnes, niveau, parent, options) { + const { tri, cleNormalisee, statuts } = options; + const colonne = colonnes[niveau - 1]; + const signe = tri !== null && tri.colonne === colonne ? SENS.get(tri.sens) : 1; + + // La valeur d'une ligne : le segment de sa clé, son rang d'ordre — un + // nombre, une chaîne, ou null pour le groupe vide — et son libellé. + const valeurDe = (p) => { + if (colonne === 'statut') { + const statut = p.exclu ? 'exclue' : (statuts?.get(p.id) ?? 'inconnu'); + return { segment: `statut:${statut}`, rang: RANGS_STATUT.get(statut), libelle: null, cleLibelle: GROUPES_STATUT.get(statut) }; + } + if (colonne === 'exclu') { + return p.exclu + ? { segment: 'exclu:oui', rang: 1, libelle: null, cleLibelle: 'participants.grille.exclue' } + : { segment: 'exclu:non', rang: 0, libelle: null, cleLibelle: 'participants.grille.presente' }; + } + if (p[colonne] === null) return { segment: 'vide', rang: null, libelle: null, cleLibelle: 'participants.groupe.vide' }; + const cle = cleNormalisee(p[colonne]); + return { segment: `texte:${segment(cle)}`, rang: cle, libelle: p[colonne], cleLibelle: null }; + }; + + const parSegment = new Map(); + for (const p of lignes) { + const valeur = valeurDe(p); + const groupe = parSegment.get(valeur.segment); + if (groupe === undefined) { + parSegment.set(valeur.segment, { ...valeur, plusPetitId: p.id, lignes: [p] }); + continue; + } + groupe.lignes.push(p); + if (p.id < groupe.plusPetitId) { + groupe.plusPetitId = p.id; + groupe.libelle = valeur.libelle; + } + } + + // Deux groupes ont deux rangs distincts : la comparaison ne rend jamais 0. + const comparer = (a, b) => { + if (a.rang === null) return 1; + if (b.rang === null) return -1; + const ecart = typeof a.rang === 'number' ? a.rang - b.rang : comparerTextes(a.rang, b.rang); + return ecart * signe; + }; + return [...parSegment.values()].sort(comparer).map(({ segment: propre, libelle, cleLibelle, lignes: membres }) => { + const cle = parent === null ? propre : `${parent}/${propre}`; + return { + cle, + niveau, + colonne, + libelle, + cleLibelle, + compte: membres.length, + lignes: membres, + sousGroupes: niveau === colonnes.length ? null : grouperNiveau(membres, colonnes, niveau + 1, cle, options), + }; + }); +} + +/** + * Les lignes que montre la liste groupée : celles de chaque groupe, groupe + * après groupe, et dans un groupe sous-groupé, sous-groupe après + * sous-groupe, hors des groupes repliés et de tout ce qu'ils contiennent. + * La navigation au clavier ne parcourt qu'elles. + * + * @param {Array<{cle: string, lignes: Array, sousGroupes: Array|null}>} groupes ceux de groupesGrille + * @param {Set} replies les clés des groupes repliés + * @returns {Array} + */ +export function lignesVisibles(groupes, replies) { + return groupes.flatMap(({ cle, lignes, sousGroupes }) => { + if (replies.has(cle)) return []; + return sousGroupes === null ? lignes : lignesVisibles(sousGroupes, replies); + }); +} + +/** + * Les groupes repliés après la bascule du groupe cle : replié, il se + * déplie ; déplié, il se replie. Rend un nouvel ensemble. + * + * @param {Set} replies + * @param {string} cle + * @returns {Set} + */ +export function basculerGroupe(replies, cle) { + const suivants = new Set(replies); + if (suivants.has(cle)) suivants.delete(cle); + else suivants.add(cle); + return suivants; +} + +/** + * Les clés des groupes qui comptent la personne id, du premier niveau au + * dernier ; vide quand aucun ne la compte — le filtre l'a retirée. + * + * @param {Array<{cle: string, lignes: Array<{id: number}>, sousGroupes: Array|null}>} groupes + * @param {number} id + * @returns {Array} + */ +export function cheminDe(groupes, id) { + const groupe = groupes.find(({ lignes }) => lignes.some((p) => p.id === id)); + if (groupe === undefined) return []; + return [groupe.cle, ...(groupe.sousGroupes === null ? [] : cheminDe(groupe.sousGroupes, id))]; +} + /** * La cellule qu'atteint une touche depuis la cellule position, dans une * grille de lignes × colonnes. Les flèches bougent d'une cellule et restent diff --git a/src/interface/participants/grille.test.js b/src/interface/participants/grille.test.js index 6b6a085..2e5c2f3 100644 --- a/src/interface/participants/grille.test.js +++ b/src/interface/participants/grille.test.js @@ -4,19 +4,29 @@ // Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2) : les // lignes de la grille, triées et filtrées, la navigation au clavier, // l'orthographe d'appartenance à reprendre, l'itinéraire d'une personne dans -// le retenu, ses réservations, et l'ordre des colonnes, celui de l'export. +// le retenu, ses réservations, l'ordre des colonnes, celui de l'export, et +// les groupes de la liste groupée par une colonne ou plusieurs, imbriqués. // Les noms d'épreuve sont inventés. import assert from 'node:assert/strict'; import { describe, test } from '../../../test/lanceur.js'; import { cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js'; import { COLONNES, + GROUPEMENTS, + basculerGroupe, celluleSuivante, + cheminDe, + choisirGroupement, cleDeColonne, + cleDeGroupement, cleDeStatut, + colonnesOffertes, entreeValide, + groupesGrille, itineraire, lignesGrille, + lignesVisibles, + niveauxAffiches, nomExport, orthographeExistante, reservationsDe, @@ -338,3 +348,442 @@ describe('entreeValide : Entrée valide, Maj+Entrée garde le saut de ligne des assert.equal(entreeValide({ maj: true, multiligne: false }), true); }); }); + +describe('groupesGrille : la liste groupée par une colonne', () => { + // Les lignes de la grille, triées et filtrées comme la section les rend. + const lignesDe = (liste, { tri = null, filtre = '' } = {}) => lignesGrille(liste, { tri, filtre }, cleNormalisee); + const grouper = (lignes, colonne, { tri = null, statuts = null } = {}) => groupesGrille(lignes, [colonne], { tri, cleNormalisee, statuts }); + // Chaque groupe réduit à sa clé, son libellé, son compte et ses lignes. + const resume = (groupes) => + groupes.map(({ cle, libelle, cleLibelle, compte, lignes }) => ({ cle, libelle, cleLibelle, compte, ids: ids(lignes) })); + + const membres = [ + personne(5, 'Lisonnet', { appartenance: 'club des merles' }), + personne(4, 'Grisaille'), + personne(3, 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(2, 'Lacasse', { appartenance: 'Cercle des Pinsons' }), + personne(1, 'Pervenche', { appartenance: 'CLUB DES MERLES' }), + ]; + + test('les colonnes groupables : celles de la grille, puis le statut calculé, chacune avec sa clé de libellé', () => { + assert.deepEqual(GROUPEMENTS, [...COLONNES, 'statut']); + assert.ok(GROUPEMENTS.length > 0); + for (const colonne of COLONNES) assert.equal(cleDeGroupement(colonne), cleDeColonne(colonne), colonne); + assert.equal(cleDeGroupement('statut'), 'participants.groupe.statut'); + assert.throws(() => cleDeGroupement('id'), RangeError); + }); + + test('par appartenance : un groupe par clé normalisée, nommé par l’orthographe du plus petit identifiant ; le groupe vide en dernier', () => { + assert.deepEqual(resume(grouper(lignesDe(membres), 'appartenance')), [ + { cle: 'texte:cercle des pinsons', libelle: 'Cercle des Pinsons', cleLibelle: null, compte: 1, ids: [2] }, + { cle: 'texte:club des merles', libelle: 'CLUB DES MERLES', cleLibelle: null, compte: 3, ids: [1, 3, 5] }, + { cle: 'vide', libelle: null, cleLibelle: 'participants.groupe.vide', compte: 1, ids: [4] }, + ]); + }); + + test('le libellé ne dépend pas du tri : le plus petit identifiant le donne, croissant comme décroissant', () => { + for (const sens of ['croissant', 'decroissant']) { + const lignes = lignesDe(membres, { tri: { colonne: 'nom', sens } }); + const merles = grouper(lignes, 'appartenance').find(({ cle }) => cle === 'texte:club des merles'); + assert.equal(merles.libelle, 'CLUB DES MERLES', sens); + } + }); + + test('trié par la colonne groupée, les groupes suivent son sens ; le groupe vide reste après toute valeur', () => { + const tri = { colonne: 'appartenance', sens: 'decroissant' }; + const groupes = grouper(lignesDe(membres, { tri }), 'appartenance', { tri }); + assert.deepEqual( + groupes.map(({ cle }) => cle), + ['texte:club des merles', 'texte:cercle des pinsons', 'vide'], + ); + }); + + test('trié par une autre colonne, les groupes sont croissants et chaque groupe garde l’ordre du tri', () => { + const tri = { colonne: 'nom', sens: 'decroissant' }; + const groupes = grouper(lignesDe(membres, { tri }), 'appartenance', { tri }); + assert.deepEqual(resume(groupes).map(({ cle, ids: suite }) => [cle, suite]), [ + ['texte:cercle des pinsons', [2]], + ['texte:club des merles', [1, 3, 5]], + ['vide', [4]], + ]); + const parNom = grouper(lignesDe(membres, { tri: { colonne: 'nom', sens: 'croissant' } }), 'appartenance'); + assert.deepEqual(parNom[1].lignes.map(({ nom }) => nom), ['Lisonnet', 'Ombrelle', 'Pervenche']); + }); + + test('le filtre passe d’abord : un groupe sans ligne disparaît, et chaque compte est celui des lignes montrées', () => { + const groupes = grouper(lignesDe(membres, { filtre: 'merles' }), 'appartenance'); + assert.deepEqual(resume(groupes), [ + { cle: 'texte:club des merles', libelle: 'CLUB DES MERLES', cleLibelle: null, compte: 3, ids: [1, 3, 5] }, + ]); + const aucun = grouper(lignesDe(membres, { filtre: 'absent' }), 'appartenance'); + assert.deepEqual(aucun, []); + }); + + test('chaque colonne de texte groupe par sa clé normalisée, le vide après toute valeur, dans les deux sens', () => { + const textes = COLONNES.filter((colonne) => colonne !== 'exclu' && colonne !== 'nom'); + assert.ok(textes.length > 0); + for (const colonne of textes) { + const liste = [ + personne(1, 'Ombrelle', { [colonne]: 'Zinnia' }), + personne(2, 'Pervenche'), + personne(3, 'Lacasse', { [colonne]: 'acacia' }), + personne(4, 'Grisaille', { [colonne]: 'ZINNIA' }), + ]; + for (const sens of ['croissant', 'decroissant']) { + const tri = { colonne, sens }; + const groupes = grouper(lignesDe(liste, { tri }), colonne, { tri }); + const attendues = sens === 'croissant' ? ['texte:acacia', 'texte:zinnia', 'vide'] : ['texte:zinnia', 'texte:acacia', 'vide']; + assert.deepEqual(groupes.map(({ cle }) => cle), attendues, `${colonne} ${sens}`); + assert.equal(groupes.find(({ cle }) => cle === 'texte:zinnia').libelle, 'Zinnia', `${colonne} ${sens}`); + } + } + // Le nom n'est jamais null : deux homonymes forment un groupe. + const homonymes = grouper(lignesDe([personne(2, 'Ombrelle'), personne(1, 'OMBRELLE'), personne(3, 'Lacasse')]), 'nom'); + assert.deepEqual(resume(homonymes).map(({ libelle, ids: suite }) => [libelle, suite]), [ + ['Lacasse', [3]], + ['OMBRELLE', [1, 2]], + ]); + }); + + test('par exclusion : présentes avant exclues, exclues d’abord quand l’exclusion est triée décroissante', () => { + const liste = [personne(1, 'Ombrelle', { exclu: true }), personne(2, 'Pervenche'), personne(3, 'Lacasse')]; + assert.deepEqual(resume(grouper(lignesDe(liste), 'exclu')), [ + { cle: 'exclu:non', libelle: null, cleLibelle: 'participants.grille.presente', compte: 2, ids: [2, 3] }, + { cle: 'exclu:oui', libelle: null, cleLibelle: 'participants.grille.exclue', compte: 1, ids: [1] }, + ]); + const tri = { colonne: 'exclu', sens: 'decroissant' }; + assert.deepEqual( + grouper(lignesDe(liste, { tri }), 'exclu', { tri }).map(({ cle }) => cle), + ['exclu:oui', 'exclu:non'], + ); + }); + + test('par statut : ancré, partiellement fixé, mobile, puis exclue, puis non calculé, quel que soit le tri', () => { + const liste = [ + personne(1, 'Ombrelle'), + personne(2, 'Pervenche'), + personne(3, 'Lacasse'), + personne(4, 'Grisaille', { exclu: true }), + personne(5, 'Lisonnet'), + personne(6, 'Brindelle'), + ]; + // 5 manque aux statuts calculés : une configuration incohérente le + // laisse non calculé. + const statuts = new Map([ + [1, 'mobile'], + [2, 'ancre'], + [3, 'partiel'], + [6, 'ancre'], + ]); + const attendu = [ + { cle: 'statut:ancre', libelle: null, cleLibelle: 'participants.statut.ancre', compte: 2, ids: [2, 6] }, + { cle: 'statut:partiel', libelle: null, cleLibelle: 'participants.statut.partiel', compte: 1, ids: [3] }, + { cle: 'statut:mobile', libelle: null, cleLibelle: 'participants.statut.mobile', compte: 1, ids: [1] }, + { cle: 'statut:exclue', libelle: null, cleLibelle: 'participants.grille.exclue', compte: 1, ids: [4] }, + { cle: 'statut:inconnu', libelle: null, cleLibelle: 'participants.groupe.statutInconnu', compte: 1, ids: [5] }, + ]; + assert.deepEqual(resume(grouper(lignesDe(liste), 'statut', { statuts })), attendu); + const tri = { colonne: 'nom', sens: 'decroissant' }; + const triees = grouper(lignesDe(liste, { tri }), 'statut', { tri, statuts }); + assert.deepEqual(triees.map(({ cle }) => cle), attendu.map(({ cle }) => cle)); + assert.deepEqual(ids(triees[0].lignes), [2, 6]); + // Sans statuts calculés, toute personne présente est non calculée. + assert.deepEqual( + grouper(lignesDe(liste), 'statut').map(({ cle, compte }) => [cle, compte]), + [ + ['statut:exclue', 1], + ['statut:inconnu', 5], + ], + ); + }); + + test('l’ordre reçu des participants ne change rien : mêmes groupes, mêmes lignes', () => { + const renverse = [...membres].reverse(); + const tri = { colonne: 'appartenance', sens: 'croissant' }; + assert.deepEqual(resume(grouper(lignesDe(renverse, { tri }), 'appartenance', { tri })), resume(grouper(lignesDe(membres, { tri }), 'appartenance', { tri }))); + }); + + test('chaque ligne dans un seul groupe, aucune perdue ; la liste reçue reste intacte', () => { + const lignes = lignesDe(membres); + const copie = structuredClone(lignes); + assert.ok(GROUPEMENTS.length > 0); + for (const colonne of GROUPEMENTS) { + const groupes = grouper(lignes, colonne, { statuts: new Map() }); + const toutes = groupes.flatMap(({ lignes: l }) => ids(l)).sort((a, b) => a - b); + assert.deepEqual(toutes, [1, 2, 3, 4, 5], colonne); + assert.ok(groupes.every(({ compte, lignes: l }) => compte === l.length && compte > 0), colonne); + assert.equal(new Set(groupes.map(({ cle }) => cle)).size, groupes.length, colonne); + } + assert.deepEqual(lignes, copie); + }); + + test('une colonne inconnue : RangeError', () => { + assert.throws(() => grouper(lignesDe(membres), 'id'), RangeError); + }); + + test('un seul niveau : chaque groupe est une feuille, de niveau 1, qui nomme sa colonne', () => { + const groupes = grouper(lignesDe(membres), 'appartenance'); + assert.ok(groupes.length > 0); + for (const groupe of groupes) { + assert.equal(groupe.sousGroupes, null, groupe.cle); + assert.equal(groupe.niveau, 1, groupe.cle); + assert.equal(groupe.colonne, 'appartenance', groupe.cle); + } + }); +}); + +describe('groupesGrille sur plusieurs colonnes : des groupes imbriqués', () => { + const lignesDe = (liste, { tri = null, filtre = '' } = {}) => lignesGrille(liste, { tri, filtre }, cleNormalisee); + const grouper = (lignes, colonnes, { tri = null, statuts = null } = {}) => groupesGrille(lignes, colonnes, { tri, cleNormalisee, statuts }); + // L'arbre réduit à ce qui se lit : clé, niveau, colonne, libellé, + // compte, puis les sous-groupes, ou les lignes d'une feuille. + const arbre = (groupes) => + groupes.map(({ cle, niveau, colonne, libelle, cleLibelle, compte, lignes, sousGroupes }) => ({ + cle, + niveau, + colonne, + libelle, + cleLibelle, + compte, + ...(sousGroupes === null ? { ids: ids(lignes) } : { sous: arbre(sousGroupes) }), + })); + const feuilles = (groupes) => groupes.flatMap((g) => (g.sousGroupes === null ? [g] : feuilles(g.sousGroupes))); + const tous = (groupes) => groupes.flatMap((g) => [g, ...(g.sousGroupes === null ? [] : tous(g.sousGroupes))]); + + const liste = [ + personne(5, 'Lisonnet', { appartenance: 'club des merles' }), + personne(4, 'Grisaille'), + personne(3, 'Lacasse', { appartenance: 'Cercle des Pinsons' }), + personne(2, 'Pervenche', { appartenance: 'CLUB DES MERLES', exclu: true }), + personne(1, 'Ombrelle', { appartenance: 'Club des Merles' }), + ]; + const statuts = new Map([ + [1, 'ancre'], + [3, 'mobile'], + [4, 'mobile'], + [5, 'mobile'], + ]); + + test('appartenance puis statut : chaque groupe d’appartenance porte ses groupes de statut, la clé d’un sous-groupe prolonge celle de son parent', () => { + const merles = 'texte:club des merles'; + const pinsons = 'texte:cercle des pinsons'; + assert.deepEqual(arbre(grouper(lignesDe(liste), ['appartenance', 'statut'], { statuts })), [ + { + cle: pinsons, + niveau: 1, + colonne: 'appartenance', + libelle: 'Cercle des Pinsons', + cleLibelle: null, + compte: 1, + sous: [{ cle: `${pinsons}/statut:mobile`, niveau: 2, colonne: 'statut', libelle: null, cleLibelle: 'participants.statut.mobile', compte: 1, ids: [3] }], + }, + { + cle: merles, + niveau: 1, + colonne: 'appartenance', + libelle: 'Club des Merles', + cleLibelle: null, + compte: 3, + sous: [ + { cle: `${merles}/statut:ancre`, niveau: 2, colonne: 'statut', libelle: null, cleLibelle: 'participants.statut.ancre', compte: 1, ids: [1] }, + { cle: `${merles}/statut:mobile`, niveau: 2, colonne: 'statut', libelle: null, cleLibelle: 'participants.statut.mobile', compte: 1, ids: [5] }, + { cle: `${merles}/statut:exclue`, niveau: 2, colonne: 'statut', libelle: null, cleLibelle: 'participants.grille.exclue', compte: 1, ids: [2] }, + ], + }, + { + cle: 'vide', + niveau: 1, + colonne: 'appartenance', + libelle: null, + cleLibelle: 'participants.groupe.vide', + compte: 1, + sous: [{ cle: 'vide/statut:mobile', niveau: 2, colonne: 'statut', libelle: null, cleLibelle: 'participants.statut.mobile', compte: 1, ids: [4] }], + }, + ]); + }); + + test('un groupe parent garde toutes ses lignes dans l’ordre reçu ; son compte est la somme de ceux de ses sous-groupes', () => { + const groupes = grouper(lignesDe(liste), ['appartenance', 'statut'], { statuts }); + const merles = groupes.find(({ cle }) => cle === 'texte:club des merles'); + assert.deepEqual(ids(merles.lignes), [1, 2, 5]); + for (const groupe of tous(groupes)) { + if (groupe.sousGroupes === null) continue; + assert.equal(groupe.compte, groupe.sousGroupes.reduce((somme, { compte }) => somme + compte, 0), groupe.cle); + } + }); + + test('chaque niveau s’ordonne sur sa colonne : dans le sens du tri quand il la trie, croissant sinon', () => { + const tri = { colonne: 'appartenance', sens: 'decroissant' }; + const groupes = grouper(lignesDe(liste, { tri }), ['statut', 'appartenance'], { tri, statuts }); + assert.deepEqual( + groupes.map(({ cle }) => cle), + ['statut:ancre', 'statut:mobile', 'statut:exclue'], + ); + assert.deepEqual( + groupes[1].sousGroupes.map(({ cle }) => cle), + ['statut:mobile/texte:club des merles', 'statut:mobile/texte:cercle des pinsons', 'statut:mobile/vide'], + ); + const croissants = grouper(lignesDe(liste), ['statut', 'appartenance'], { statuts }); + assert.deepEqual( + croissants[1].sousGroupes.map(({ cle }) => cle), + ['statut:mobile/texte:cercle des pinsons', 'statut:mobile/texte:club des merles', 'statut:mobile/vide'], + ); + }); + + test('toute paire de colonnes : chaque ligne dans une seule feuille, aucune perdue, des clés uniques à tout niveau, d’autant de segments que le niveau', () => { + const lignes = lignesDe(liste); + const copie = structuredClone(lignes); + let paires = 0; + for (const premiere of GROUPEMENTS) { + for (const seconde of GROUPEMENTS) { + if (premiere === seconde) continue; + paires += 1; + const groupes = grouper(lignes, [premiere, seconde], { statuts }); + const cas = `${premiere} puis ${seconde}`; + assert.deepEqual(feuilles(groupes).flatMap(({ lignes: l }) => ids(l)).sort((a, b) => a - b), [1, 2, 3, 4, 5], cas); + const cles = tous(groupes).map(({ cle }) => cle); + assert.equal(new Set(cles).size, cles.length, cas); + for (const groupe of tous(groupes)) assert.equal(groupe.cle.split('/').length, groupe.niveau, `${cas} ${groupe.cle}`); + assert.ok(feuilles(groupes).every(({ niveau, colonne }) => niveau === 2 && colonne === seconde), cas); + } + } + assert.equal(paires, GROUPEMENTS.length * (GROUPEMENTS.length - 1)); + assert.deepEqual(lignes, copie); + }); + + test('une valeur qui porte « / » ou « % » ne se lit jamais comme un séparateur : sa clé les échappe', () => { + const portees = [ + personne(1, 'Ombrelle', { appartenance: 'Nord/Sud', prenom: 'Iris' }), + personne(2, 'Pervenche', { appartenance: 'Nord', prenom: 'Sud/Iris' }), + personne(3, 'Lacasse', { appartenance: '100%', prenom: 'Iris' }), + ]; + const groupes = grouper(lignesDe(portees), ['appartenance', 'prenom']); + assert.deepEqual( + tous(groupes).map(({ cle }) => cle), + ['texte:100%25', 'texte:100%25/texte:iris', 'texte:nord', 'texte:nord/texte:sud%2Firis', 'texte:nord%2Fsud', 'texte:nord%2Fsud/texte:iris'], + ); + }); + + test('aucune colonne, une colonne répétée, une colonne inconnue, une colonne seule hors d’une liste : RangeError', () => { + const lignes = lignesDe(liste); + assert.throws(() => grouper(lignes, []), RangeError); + assert.throws(() => grouper(lignes, ['statut', 'statut'], { statuts }), RangeError); + assert.throws(() => grouper(lignes, ['appartenance', 'id']), RangeError); + assert.throws(() => grouper(lignes, 'appartenance'), RangeError); + }); +}); + +describe('lignesVisibles, cheminDe : les groupes repliés et la navigation', () => { + const liste = [ + personne(1, 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(2, 'Pervenche', { appartenance: 'Cercle des Pinsons' }), + personne(3, 'Lacasse', { appartenance: 'Club des Merles' }), + personne(4, 'Grisaille'), + personne(5, 'Lisonnet', { appartenance: 'Atelier des Grives' }), + ]; + const groupes = groupesGrille(lignesGrille(liste, { tri: null, filtre: '' }, cleNormalisee), ['appartenance'], { + tri: null, + cleNormalisee, + statuts: null, + }); + + test('sans groupe replié, les lignes de chaque groupe, groupe après groupe', () => { + assert.deepEqual(ids(lignesVisibles(groupes, new Set())), [5, 2, 1, 3, 4]); + }); + + test('un groupe replié retire exactement ses lignes ; tout replier n’en laisse aucune', () => { + assert.deepEqual(ids(lignesVisibles(groupes, new Set(['texte:cercle des pinsons']))), [5, 1, 3, 4]); + assert.deepEqual(ids(lignesVisibles(groupes, new Set(['texte:club des merles', 'vide']))), [5, 2]); + assert.deepEqual(lignesVisibles(groupes, new Set(groupes.map(({ cle }) => cle))), []); + // Une clé sans groupe ne retire rien. + assert.deepEqual(ids(lignesVisibles(groupes, new Set(['texte:absent']))), [5, 2, 1, 3, 4]); + }); + + test('celluleSuivante sur les lignes visibles passe par-dessus un groupe replié', () => { + const visibles = lignesVisibles(groupes, new Set(['texte:cercle des pinsons'])); + const taille = { lignes: visibles.length, colonnes: COLONNES.length }; + const depuis = { ligne: ids(visibles).indexOf(5), colonne: 2 }; + const arrivee = celluleSuivante(depuis, 'ArrowDown', taille); + assert.equal(visibles[arrivee.ligne].id, 1); + const fin = celluleSuivante({ ligne: visibles.length - 1, colonne: COLONNES.length - 1 }, 'Tab', taille); + assert.equal(fin, null); + }); + + test('basculerGroupe : replie un groupe déplié, déplie un groupe replié, sans toucher l’ensemble reçu', () => { + const replies = new Set(['vide']); + assert.deepEqual([...basculerGroupe(replies, 'texte:club des merles')], ['vide', 'texte:club des merles']); + assert.deepEqual([...basculerGroupe(replies, 'vide')], []); + assert.deepEqual([...replies], ['vide']); + }); + + test('cheminDe : la clé du groupe d’une personne, vide hors de tout groupe', () => { + assert.deepEqual(cheminDe(groupes, 3), ['texte:club des merles']); + assert.deepEqual(cheminDe(groupes, 4), ['vide']); + assert.deepEqual(cheminDe(groupes, 99), []); + }); + + // Les mêmes personnes groupées par appartenance puis par prénom. + const imbriques = groupesGrille( + lignesGrille( + liste.map((p) => ({ ...p, prenom: p.id % 2 === 0 ? 'Iris' : 'Théo' })), + { tri: null, filtre: '' }, + cleNormalisee, + ), + ['appartenance', 'prenom'], + { tri: null, cleNormalisee, statuts: null }, + ); + const MERLES = 'texte:club des merles'; + + test('imbriqués : les lignes visibles suivent les sous-groupes ; replier un sous-groupe ne retire que les siennes, replier son parent retire tout le parent', () => { + assert.deepEqual(ids(lignesVisibles(imbriques, new Set())), [5, 2, 1, 3, 4]); + assert.deepEqual(ids(lignesVisibles(imbriques, new Set([`${MERLES}/texte:theo`]))), [5, 2, 4]); + assert.deepEqual(ids(lignesVisibles(imbriques, new Set([MERLES]))), [5, 2, 4]); + // Un sous-groupe déplié sous un parent replié reste caché. + assert.deepEqual(ids(lignesVisibles(imbriques, new Set([MERLES, 'vide/texte:iris']))), [5, 2]); + }); + + test('cheminDe imbriqué : les clés de chaque groupe qui compte la personne, du plus large au plus étroit', () => { + assert.deepEqual(cheminDe(imbriques, 3), [MERLES, `${MERLES}/texte:theo`]); + assert.deepEqual(cheminDe(imbriques, 4), ['vide', 'vide/texte:iris']); + assert.deepEqual(cheminDe(imbriques, 99), []); + }); +}); + +describe('choisirGroupement, niveauxAffiches, colonnesOffertes : les niveaux du groupement', () => { + test('choisir une colonne au premier niveau libre l’ajoute ; la valeur vide à un niveau le retire, lui et ceux d’après', () => { + assert.deepEqual(choisirGroupement([], 0, 'appartenance'), ['appartenance']); + assert.deepEqual(choisirGroupement(['appartenance'], 1, 'statut'), ['appartenance', 'statut']); + assert.deepEqual(choisirGroupement(['appartenance', 'statut', 'nom'], 1, ''), ['appartenance']); + assert.deepEqual(choisirGroupement(['appartenance', 'statut'], 0, ''), []); + }); + + test('changer la colonne d’un niveau garde les suivants, hors celle qui y était déjà', () => { + assert.deepEqual(choisirGroupement(['appartenance', 'statut', 'nom'], 0, 'prenom'), ['prenom', 'statut', 'nom']); + assert.deepEqual(choisirGroupement(['appartenance', 'statut', 'nom'], 0, 'statut'), ['statut', 'nom']); + assert.deepEqual(choisirGroupement(['appartenance', 'statut', 'nom'], 1, 'nom'), ['appartenance', 'nom']); + }); + + test('ne modifie pas le groupement reçu ; une colonne inconnue, une prise plus haut, un niveau au-delà du premier libre : RangeError', () => { + const groupement = Object.freeze(['appartenance', 'statut']); + choisirGroupement(groupement, 1, 'nom'); + assert.deepEqual(groupement, ['appartenance', 'statut']); + assert.throws(() => choisirGroupement(groupement, 1, 'id'), RangeError); + assert.throws(() => choisirGroupement(groupement, 1, 'appartenance'), RangeError); + assert.throws(() => choisirGroupement(groupement, 3, 'nom'), RangeError); + assert.throws(() => choisirGroupement(groupement, -1, 'nom'), RangeError); + }); + + test('les niveaux affichés : un par colonne choisie, puis le suivant tant qu’une colonne reste libre', () => { + assert.deepEqual(niveauxAffiches([]), [0]); + assert.deepEqual(niveauxAffiches(['appartenance', 'statut']), [0, 1, 2]); + assert.deepEqual(niveauxAffiches(GROUPEMENTS.slice(0, -1)), GROUPEMENTS.map((_, rang) => rang)); + assert.deepEqual(niveauxAffiches(GROUPEMENTS), GROUPEMENTS.map((_, rang) => rang)); + }); + + test('les colonnes offertes à un niveau : celles de GROUPEMENTS, dans leur ordre, hors celles des niveaux plus larges', () => { + assert.deepEqual(colonnesOffertes([], 0), GROUPEMENTS); + assert.deepEqual(colonnesOffertes(['appartenance', 'statut'], 0), GROUPEMENTS); + assert.deepEqual(colonnesOffertes(['appartenance', 'statut'], 1), GROUPEMENTS.filter((c) => c !== 'appartenance')); + assert.deepEqual(colonnesOffertes(['appartenance', 'statut'], 2), GROUPEMENTS.filter((c) => c !== 'appartenance' && c !== 'statut')); + }); +}); diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index 8106c61..4c5a8b4 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -473,6 +473,25 @@ export const ECRANS = Object.freeze([ return m.racine; }, }, + { + nom: 'participants.groupes', + mode: 'ecriture', + async atteindre(contexte) { + // La liste groupée par appartenance, choisie au clavier dans le + // sélecteur, puis le premier groupe replié. + const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' }); + const choix = await element(m, '[data-section="participants"] [data-champ="grouper"]'); + choix.focus(); + for (let essai = 0; essai < choix.options.length && choix.value !== 'appartenance'; essai += 1) { + await touche('ArrowDown'); + flushSync(); + } + await element(m, '[data-groupe] [data-commande="basculerGroupe"]'); + const bouton = await cliquer(m, '[data-groupe] [data-commande="basculerGroupe"]'); + await attendre('un groupe replié', () => bouton.getAttribute('aria-expanded') === 'false' && m.racine.querySelectorAll('[data-groupe]').length > 1); + return m.racine; + }, + }, { nom: 'import.apercu', mode: 'ecriture',