From fbff3a6c5f27d0a6412044c1bf7eaaee77c62ff8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 8 Oct 2026 03:49:36 -0400 Subject: [PATCH] [ADD] participants: group the list by columns, with counts and collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The participants list can now be grouped by one or more columns, nested level by level: appartenance (by its normalised key, labelled with its first spelling), status, exclusion, or any text column. Each group header shows its value, its count and a keyboard-operable collapse button, with Collapse all and Expand all. The filter applies first, groups follow the current sort, arrow keys skip headers and collapsed rows, and an edit moves its row to its new group. Grouping is session state only and never writes the event. Checked: 2811 node, 565 browser tests, index alone. --- FR --- [ADD] participants : grouper la liste par colonnes, compte et repli La liste des participants se groupe par une ou plusieurs colonnes, imbriquées niveau par niveau : appartenance (par sa clé normalisée, sous sa première orthographe), statut, exclusion, ou toute colonne de texte. Chaque en-tête de groupe montre sa valeur, son compte et un bouton de repli au clavier, avec Tout replier et Tout déplier. Le filtre s'applique d'abord, les groupes suivent le tri, les flèches sautent les en-têtes et les lignes repliées, et une saisie déplace sa ligne dans son nouveau groupe. État de séance seul, aucune écriture. Vérifié : 2811 node, 565. Assisted-by: Claude Opus 5.5 --- src/interface/libelles/participants.js | 18 +- src/interface/participants/Grille.svelte | 309 ++++++++---- .../Participants.navigateur.test.js | 470 +++++++++++++++++- .../participants/SectionParticipants.svelte | 84 +++- src/interface/participants/grille.js | 252 +++++++++- src/interface/participants/grille.test.js | 451 ++++++++++++++++- test/navigateur/ecrans.js | 19 + 7 files changed, 1504 insertions(+), 99 deletions(-) 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',