feat(vue-fiches): fiche= dans l'URL, fiche à fiche, historique push/replace/back (B12-M2/M3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
This commit is contained in:
Jules Neny
2026-10-03 08:57:16 +02:00
co-authored by Claude Opus 5.5
parent 55027ba482
commit fe1043e321
3 changed files with 217 additions and 3 deletions
+88 -2
View File
@@ -17,6 +17,7 @@ import type { MiniFiche } from '~/composables/useFicheAdapter'
import { import {
lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes, lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes,
triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs, triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs,
lireFiche, queryAvecFiche, positionFiche, modeOuverture, modeFermeture, empreinteListe,
type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches, type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches,
} from '~/utils/vueFiches' } from '~/utils/vueFiches'
@@ -50,11 +51,29 @@ export function useVueFiches(
})) }))
// ── Écriture ── // ── É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<string, string> | null = null
watch(() => route.query, () => { enAttente = null })
const queryBase = () => enAttente ?? route.query
function naviguer(query: Record<string, string>, mode: 'push' | 'replace' = 'replace') {
enAttente = query
if (mode === 'push') router.push({ query })
else router.replace({ query })
}
function etatDepuis(base: Record<string, unknown>): EtatFiches {
const b = lireQuery(base, cfg.value)
return { ...b, q: q.value, filtres: filtresValides(b.filtres, cfg.value) }
}
function ecrire(partiel: Partial<EtatFiches>) { function ecrire(partiel: Partial<EtatFiches>) {
const nouvel = { ...etat.value, ...partiel } const base = queryBase()
const nouvel = { ...etatDepuis(base), ...partiel }
qEcrit = nouvel.q qEcrit = nouvel.q
if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null } 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) { 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. */ /** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */
const survolId = ref<string | number | null>(null) const survolId = ref<string | number | null>(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<MiniFiche | null>(() => {
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({ return reactive({
cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs, cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs,
idsResultat, nbSansAdresse, total, nbActifs, survolId, idsResultat, nbSansAdresse, total, nbActifs, survolId,
ficheId, ficheOuverte, position, centrage,
setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue, setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue,
setSansAdresse, voirSansAdresse, setSansAdresse, voirSansAdresse,
ouvrirFiche, fermerFiche, fichePrecedente, ficheSuivante, voirSurCarte, filtrerDepuisFiche,
}) })
} }
+58
View File
@@ -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(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)') 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}`) console.log(`\n${n - fail}/${n}`)
process.exit(fail ? 1 : 0) process.exit(fail ? 1 : 0)
+71 -1
View File
@@ -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 * 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<string, string> { export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record<string, string> {
const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)]) 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[] { export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] {
return groupes.flatMap((g) => g.fiches) 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<string, string> {
const out: Record<string, string> = {}
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('&')
}