From fe1043e321804b604e03541a30dfb54bc11bfe2a Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Sat, 3 Oct 2026 08:57:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(vue-fiches):=20fiche=3D=20dans=20l'URL,=20?= =?UTF-8?q?fiche=20=C3=A0=20fiche,=20historique=20push/replace/back=20(B12?= =?UTF-8?q?-M2/M3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S --- composables/useVueFiches.ts | 90 ++++++++++++++++++++++++++++++++++++- scripts/test-vue-fiches.mjs | 58 ++++++++++++++++++++++++ utils/vueFiches.ts | 72 ++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/composables/useVueFiches.ts b/composables/useVueFiches.ts index 102adf0..879f083 100644 --- a/composables/useVueFiches.ts +++ b/composables/useVueFiches.ts @@ -17,6 +17,7 @@ import type { MiniFiche } from '~/composables/useFicheAdapter' import { lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes, triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs, + lireFiche, queryAvecFiche, positionFiche, modeOuverture, modeFermeture, empreinteListe, type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches, } from '~/utils/vueFiches' @@ -50,11 +51,29 @@ export function useVueFiches( })) // ── Écriture ── + // La navigation est asynchrone : une query écrite n'est dans `route.query` + // qu'au tick suivant. Deux écritures du même tick (« Voir sur la carte » : + // Mixte, puis la sous-vue de la page) partent donc de la dernière query + // écrite, sinon la seconde efface la première. + let enAttente: Record | null = null + watch(() => route.query, () => { enAttente = null }) + const queryBase = () => enAttente ?? route.query + function naviguer(query: Record, mode: 'push' | 'replace' = 'replace') { + enAttente = query + if (mode === 'push') router.push({ query }) + else router.replace({ query }) + } + function etatDepuis(base: Record): EtatFiches { + const b = lireQuery(base, cfg.value) + return { ...b, q: q.value, filtres: filtresValides(b.filtres, cfg.value) } + } + function ecrire(partiel: Partial) { - const nouvel = { ...etat.value, ...partiel } + const base = queryBase() + const nouvel = { ...etatDepuis(base), ...partiel } qEcrit = nouvel.q if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null } - router.replace({ query: ecrireQuery(nouvel, cfg.value, route.query) }) + naviguer(ecrireQuery(nouvel, cfg.value, base)) } function setQ(v: string) { @@ -155,11 +174,78 @@ export function useVueFiches( /** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */ const survolId = ref(null) + // ── Fiche ouverte (B12) : `fiche=` dans l'URL, lu aussi au rendu serveur ── + const ficheId = computed(() => lireFiche(route.query)) + /** La fiche ouverte, cherchée dans TOUTES les fiches (elle peut être hors filtres). null = pas encore chargée ou inconnue. */ + const ficheOuverte = computed(() => { + const id = ficheId.value + return id == null ? null : toValue(fiches).find((f) => String(f.id) === id) ?? null + }) + /** Rang dans l'ordre courant ; null = hors de vos filtres (ou pas de fiche). */ + const position = computed(() => positionFiche(ordre.value, ficheId.value)) + /** + * Empreinte de la liste au moment où CETTE page a ajouté l'entrée + * d'historique de la fiche ; null = fiche venue d'un lien partagé. + */ + let listeALOuverture: string | null = null + + function ouvrirFiche(id: string | number) { + if (String(id) === lireFiche(queryBase())) return + const base = queryBase() + const mode = modeOuverture(lireFiche(base)) + if (mode === 'push') listeALOuverture = empreinteListe(base) + naviguer(queryAvecFiche(base, id), mode) + } + + function fermerFiche() { + if (lireFiche(queryBase()) == null) return + const mode = modeFermeture(listeALOuverture != null, listeALOuverture === empreinteListe(queryBase())) + listeALOuverture = null + if (mode === 'back') { enAttente = null; router.back() } + else naviguer(queryAvecFiche(queryBase(), null)) + } + + /** Étiquette cliquée dans le détail : ajoute le filtre ; `fermer` (téléphone) referme la fiche dans le même geste. */ + function filtrerDepuisFiche(dim: string, valeur: string, fermer: boolean) { + const base = queryBase() + const e = etatDepuis(base) + const vals = e.filtres[dim] ?? [] + const filtres = vals.includes(valeur) ? e.filtres : { ...e.filtres, [dim]: [...vals, valeur] } + const query = ecrireQuery({ ...e, filtres }, cfg.value, base) + if (fermer) listeALOuverture = null + naviguer(fermer ? queryAvecFiche(query, null) : query) + } + + // Retour arrière du navigateur (ou lien interne) : l'URL a perdu `fiche=`. + watch(ficheId, (id) => { if (id == null) listeALOuverture = null }) + + const fichePrecedente = () => { if (position.value?.precedente) ouvrirFiche(position.value.precedente.id) } + const ficheSuivante = () => { if (position.value?.suivante) ouvrirFiche(position.value.suivante.id) } + + /** + * « Voir sur la carte » : ordinateur → Mixte, la fiche reste ouverte ; + * téléphone et tablette (pas de Mixte) → Carte, la fiche se ferme (elle + * couvrirait la carte). La page lit `centrage` et centre son épingle. + */ + const centrage = ref<{ id: string; n: number } | null>(null) + function voirSurCarte(id: string | number) { + centrage.value = { id: String(id), n: (centrage.value?.n ?? 0) + 1 } + if (largeur.value === 'ordinateur' && cfg.value.etats.includes('mixte')) { + setVue('mixte') + return + } + listeALOuverture = null + const base = queryBase() + naviguer(queryAvecFiche(ecrireQuery({ ...etatDepuis(base), vue: 'carte' }, cfg.value, base), null)) + } + return reactive({ cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs, idsResultat, nbSansAdresse, total, nbActifs, survolId, + ficheId, ficheOuverte, position, centrage, setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue, setSansAdresse, voirSansAdresse, + ouvrirFiche, fermerFiche, fichePrecedente, ficheSuivante, voirSurCarte, filtrerDepuisFiche, }) } diff --git a/scripts/test-vue-fiches.mjs b/scripts/test-vue-fiches.mjs index d244354..bd71ef6 100644 --- a/scripts/test-vue-fiches.mjs +++ b/scripts/test-vue-fiches.mjs @@ -128,5 +128,63 @@ ok(eq(gt.map((x) => x.libelle), ['Martinique', 'Métropole']), 'groupe territoir ok(V.regrouper(fiches, null, cfg).length === 1, 'sans regroupement : un seul bloc') ok(eq(ids(V.ordreFiches(g)), [1, 4, 2, 3, 5]), 'ordre fiche à fiche = ordre affiché des groupes (B12)') +// ── Fiche ouverte : URL, fiche à fiche, historique (B12-M2/M3) ── +ok(V.lireFiche({ fiche: '412' }) === '412' && V.lireFiche({ fiche: 'f1-rotor' }) === 'f1-rotor', 'ouverture par URL : fiche= lu (id numérique ou texte)') +ok(V.lireFiche({}) === null && V.lireFiche({ fiche: '' }) === null && V.lireFiche({ fiche: ' ' }) === null, 'fiche= absent ou vide → pas de fiche') +ok(V.lireFiche({ fiche: ['7', '8'] }) === '7', 'fiche= répété → la première valeur') +const qListe = { vue: 'fiches', fonctions: 'Juridique,Formation', tri: 'nom' } +ok(eq(V.queryAvecFiche(qListe, 412), { ...qListe, fiche: '412' }), 'queryAvecFiche pose fiche= et garde filtres, tri, vue') +ok(eq(V.queryAvecFiche({ ...qListe, fiche: '412' }, null), qListe), 'queryAvecFiche(null) retire fiche= et rien d’autre') +const qEcrite = V.ecrireQuery({ ...V.lireQuery({ ...qListe, fiche: '412' }, cfg), tri: 'pertinence' }, cfg, { ...qListe, fiche: '412' }) +ok(qEcrite.fiche === '412' && qEcrite.tri === 'pertinence', 'un changement de tri garde la fiche ouverte') +ok(V.empreinteListe({ ...qListe, fiche: '1' }) === V.empreinteListe({ fiche: '9', tri: 'nom', fonctions: 'Juridique,Formation', vue: 'fiches' }), 'empreinte de la liste : indépendante de fiche= et de l’ordre des clés') + +const ordre = V.ordreFiches(g) // [1, 4, 2, 3, 5] +const p = V.positionFiche(ordre, '2') +ok(p && p.rang === 3 && p.total === 5 && p.precedente.id === 4 && p.suivante.id === 3, '« 3 / 5 » : précédente et suivante dans l’ordre courant') +ok(V.positionFiche(ordre, 1).precedente === null && V.positionFiche(ordre, 5).suivante === null, 'pas de bouclage aux extrémités') +const ordreFiltre = V.ordreFiches(V.regrouper(V.filtrerFiches(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg), null, cfg)) +ok(V.positionFiche(ordreFiltre, '4') === null, 'fiche hors filtres → position null (« hors de vos filtres », sans précédente/suivante)') +ok(V.positionFiche(ordre, null) === null && V.positionFiche(ordre, '999') === null, 'pas de fiche ou id inconnu → null') + +// Historique simulé, avec les mêmes décisions que composables/useVueFiches.ts +function navigateur(depart) { + const pile = [depart] + let i = 0 + let liste = null // listeALOuverture + const cur = () => pile[i] + return { + cur, + longueur: () => i + 1, + ouvrir(id) { + const mode = V.modeOuverture(V.lireFiche(cur())) + if (mode === 'push') { liste = V.empreinteListe(cur()); pile.splice(i + 1); pile.push(V.queryAvecFiche(cur(), id)); i++ } + else pile[i] = V.queryAvecFiche(cur(), id) + }, + filtrer(q) { pile[i] = q }, + fermer() { + const mode = V.modeFermeture(liste != null, liste === V.empreinteListe(cur())) + liste = null + if (mode === 'back') i-- + else pile[i] = V.queryAvecFiche(cur(), null) + }, + retour() { i--; if (V.lireFiche(cur()) == null) liste = null }, + } +} +let nav = navigateur(qListe) +nav.ouvrir(1); nav.ouvrir(4); nav.ouvrir(2) +ok(nav.longueur() === 2 && nav.cur().fiche === '2', 'ouvrir puis deux fois « suivante » : une seule entrée d’historique ajoutée') +nav.retour() +ok(nav.longueur() === 1 && V.lireFiche(nav.cur()) === null && eq(nav.cur(), qListe), 'retour arrière (téléphone) : la fiche se ferme, la liste et ses filtres restent') +nav = navigateur(qListe) +nav.ouvrir(1); nav.fermer() +ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'fermer une fiche ouverte depuis la liste : retour en arrière, historique propre') +nav = navigateur({ ...qListe, fiche: '412' }) +nav.fermer() +ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'lien partagé : fermer retire fiche= sur place, sans quitter la page') +nav = navigateur(qListe) +nav.ouvrir(1); nav.filtrer({ ...nav.cur(), echelle: 'National' }); nav.fermer() +ok(nav.cur().echelle === 'National' && V.lireFiche(nav.cur()) === null, 'filtre changé fiche ouverte : fermer garde le nouveau filtre (pas de retour arrière)') + console.log(`\n${n - fail}/${n}`) process.exit(fail ? 1 : 0) diff --git a/utils/vueFiches.ts b/utils/vueFiches.ts index df12c89..bdd88eb 100644 --- a/utils/vueFiches.ts +++ b/utils/vueFiches.ts @@ -121,7 +121,8 @@ export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches { /** * Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas - * (`territoire`, `random`, `fiche` de B12…) sont recopiés depuis `base`. + * (`territoire`, `random`…) sont recopiés depuis `base`, `fiche` aussi : un + * filtre changé garde la fiche ouverte (elle peut passer « hors de vos filtres »). */ export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record { const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)]) @@ -295,3 +296,72 @@ export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: Confi export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] { return groupes.flatMap((g) => g.fiches) } + +// ── Fiche ouverte (B12) ──────────────────────────────────────────────────── + +/** `fiche=` de l'URL ; null si absent ou vide. L'id reste une chaîne (Entraide : nombre, Réseaux : `f1-rotor`). */ +export function lireFiche(query: Query): string | null { + const v = premiere(query.fiche).trim() + return v || null +} + +/** Query avec `fiche=` posé (id) ou retiré (null), tout le reste recopié tel quel. */ +export function queryAvecFiche(query: Query, id: string | number | null): Record { + const out: Record = {} + for (const [k, v] of Object.entries(query)) { + if (k !== 'fiche' && premiere(v) !== '') out[k] = premiere(v) + } + if (id != null && String(id) !== '') out.fiche = String(id) + return out +} + +export interface PositionFiche { + /** Rang dans l'ordre courant, de 1 à total. */ + rang: number + total: number + precedente: MiniFiche | null + suivante: MiniFiche | null +} + +/** + * Place de la fiche dans l'ordre courant (filtres + tri + regroupement). + * null = fiche hors de l'ordre courant (« hors de vos filtres ») : pas de + * précédente ni de suivante. Pas de bouclage aux extrémités. + */ +export function positionFiche(ordre: MiniFiche[], id: string | number | null): PositionFiche | null { + if (id == null) return null + const i = ordre.findIndex((f) => String(f.id) === String(id)) + if (i === -1) return null + return { + rang: i + 1, + total: ordre.length, + precedente: i > 0 ? ordre[i - 1] : null, + suivante: i < ordre.length - 1 ? ordre[i + 1] : null, + } +} + +/** + * Historique (SPEC §5) : OUVRIR une fiche depuis la liste ajoute une entrée + * (le retour du téléphone la referme) ; passer d'une fiche à l'autre la + * remplace (le retour ne rejoue pas les vingt fiches parcourues). + */ +export function modeOuverture(ficheCourante: string | null): 'push' | 'replace' { + return ficheCourante == null ? 'push' : 'replace' +} + +/** + * FERMER : si l'entrée a été ajoutée par cette page ET que la liste derrière + * n'a pas changé depuis (mêmes filtres, tri, vue), on revient en arrière : + * l'historique reste propre. Sinon (lien partagé, rechargement, filtre changé + * fiche ouverte), on retire `fiche=` sur place : un retour arrière aurait + * défait les filtres choisis entre-temps. + */ +export function modeFermeture(ouverteParLaPage: boolean, listeInchangee: boolean): 'back' | 'replace' { + return ouverteParLaPage && listeInchangee ? 'back' : 'replace' +} + +/** Empreinte de la liste derrière la fiche : la query sans `fiche=`, clés triées. */ +export function empreinteListe(query: Query): string { + const q = queryAvecFiche(query, null) + return Object.keys(q).sort().map((k) => `${k}=${q[k]}`).join('&') +}