feat(vue-fiches): état unique lu et écrit dans l'URL, filtres OU/ET, compteurs, tri, regroupement (B11-M1, M2 adaptateurs)
utils/vueFiches.ts porte la logique pure, composables/useVueFiches.ts la branche sur la route ; MiniFiche gagne etiquettes, coords, date, ville, texte. Test : node scripts/test-vue-fiches.mjs (42/42). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5ce431d055
commit
38df1d5eb5
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* useFicheAdapter — B3-M2
|
||||
* useFicheAdapter — B3-M2, étendu en B11-M2
|
||||
* Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par
|
||||
* FicheMiniCard / FichesPanel. Aucune mutation des types sources.
|
||||
* FicheMiniCard / VueFiches. Aucune mutation des types sources.
|
||||
*
|
||||
* Repli résumé (brief B3, le champ `resume_court` du batch B2b n'existe pas
|
||||
* encore) : première phrase du premier champ non vide parmi une liste de
|
||||
* candidats ordonnée par type, tronquée proprement à ~110 caractères sur un
|
||||
* mot (jamais au milieu).
|
||||
*
|
||||
* B11 : chaque fiche porte ses `etiquettes` par dimension (clé = paramètre
|
||||
* d'URL de la dimension : `fonctions`, `echelle`, `territoire` pour l'Entraide ;
|
||||
* `tags`, `famille`, `pays` pour Réseaux AEP). C'est ce que filtre, trie et
|
||||
* regroupe `utils/vueFiches.ts` ; `chips` et `echelle` restent l'affichage
|
||||
* de la carte-fiche, inchangé.
|
||||
*/
|
||||
import type { Org } from '~/types/org'
|
||||
import type { StructureV2 } from '~/types/structure-v2'
|
||||
@@ -15,12 +21,25 @@ export interface MiniFiche {
|
||||
id: string | number
|
||||
nom: string
|
||||
resume: string
|
||||
/** Étiquettes affichées sur la carte-fiche (3 au plus). */
|
||||
chips: string[]
|
||||
/** Dimension d'où viennent `chips` : les rend cliquables dans la vue fiches. */
|
||||
chipsDimension?: string
|
||||
echelle?: string
|
||||
geoloc: boolean
|
||||
kind: 'org' | 'structure'
|
||||
/** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */
|
||||
etiquettes?: Record<string, string[]>
|
||||
coords?: [number, number]
|
||||
/** Date ISO si la source en a une (tri « récent »). */
|
||||
date?: string
|
||||
ville?: string
|
||||
/** Texte cherchable en plus du nom, de la ville et du résumé. */
|
||||
texte?: string
|
||||
}
|
||||
|
||||
export const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||
|
||||
const RESUME_MAX = 110
|
||||
|
||||
/** Première phrase d'un texte (coupe sur ". "/"! "/"? " ou fin de chaîne). */
|
||||
@@ -53,32 +72,68 @@ function orgFonctions(org: Org): string[] {
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
export function useFicheAdapter() {
|
||||
function toMini(source: Org | StructureV2): MiniFiche {
|
||||
function coordsDe(lat?: number | null, lon?: number | null): [number, number] | undefined {
|
||||
return lat != null && lon != null ? [lat, lon] : undefined
|
||||
}
|
||||
|
||||
/** Hashtag sans son « # » : c'est la forme de l'URL (`tags=reemploi-structurel`). */
|
||||
export function sansDiese(tag: string): string {
|
||||
return tag.replace(/^#/, '')
|
||||
}
|
||||
|
||||
/** Familles d'une structure : principale, secondaires, et 6 pour le badge recherche politique. */
|
||||
function famillesDe(s: StructureV2): string[] {
|
||||
const ids = [s.famille_principale, ...(s.familles_secondaires ?? [])]
|
||||
if (s.badges?.f6_recherche_politique) ids.push(6 as any)
|
||||
return [...new Set(ids.map(String))]
|
||||
}
|
||||
|
||||
export function toMini(source: Org | StructureV2): MiniFiche {
|
||||
if (isStructure(source)) {
|
||||
const hashtags = source.hashtags ?? []
|
||||
return {
|
||||
id: source.id,
|
||||
nom: source.nom,
|
||||
resume: buildResume([source.description_courte, source.description_longue]),
|
||||
chips: (source.hashtags ?? []).slice(0, 3),
|
||||
chips: hashtags.slice(0, 3),
|
||||
chipsDimension: 'tags',
|
||||
geoloc: source.latitude != null && source.longitude != null,
|
||||
kind: 'structure',
|
||||
etiquettes: {
|
||||
tags: hashtags.map(sansDiese),
|
||||
famille: famillesDe(source),
|
||||
pays: source.pays ? [source.pays] : [],
|
||||
},
|
||||
coords: coordsDe(source.latitude, source.longitude),
|
||||
ville: source.ville,
|
||||
// Portée de la recherche d'avant B11 sur /agences : description courte et hashtags.
|
||||
texte: [source.description_courte, ...hashtags].filter(Boolean).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
const fonctions = orgFonctions(source)
|
||||
return {
|
||||
id: source.Id,
|
||||
nom: source.nom,
|
||||
resume: buildResume([source.description_enrichie, source.description]),
|
||||
chips: orgFonctions(source),
|
||||
chips: fonctions.slice(0, 3),
|
||||
chipsDimension: 'fonctions',
|
||||
echelle: source.echelle,
|
||||
geoloc: source.latitude != null && source.longitude != null,
|
||||
kind: 'org',
|
||||
etiquettes: {
|
||||
fonctions,
|
||||
echelle: source.echelle ? [source.echelle] : [],
|
||||
territoire: [source.territoire && DOM_TOM.includes(source.territoire) ? source.territoire : 'Métropole'],
|
||||
},
|
||||
coords: coordsDe(source.latitude, source.longitude),
|
||||
date: source.submitted_at,
|
||||
ville: source.localisation_ville,
|
||||
}
|
||||
}
|
||||
|
||||
export function useFicheAdapter() {
|
||||
return { toMini }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* useVueFiches — B11-M1. L'état de la vue fiches, branché sur l'URL.
|
||||
*
|
||||
* L'URL est la source de vérité (SPEC-vue-fiches §5) : l'état se LIT dans
|
||||
* `route.query` (surveillée : un lien interne vers `?vue=fiches` depuis la même
|
||||
* page change l'écran) et s'ÉCRIT par `router.replace` (pas d'entrée
|
||||
* d'historique par clic de filtre). Seule la recherche a un tampon local, pour
|
||||
* ne pas perdre de lettres pendant la frappe : elle part dans l'URL 250 ms
|
||||
* après la dernière touche.
|
||||
*
|
||||
* La page crée l'état (elle en a besoin pour sa carte) et le passe tel quel à
|
||||
* `<VueFiches :vf="vf">`. Logique pure et tests : `utils/vueFiches.ts`,
|
||||
* `scripts/test-vue-fiches.mjs`.
|
||||
*/
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
import {
|
||||
lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes,
|
||||
triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs,
|
||||
type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches,
|
||||
} from '~/utils/vueFiches'
|
||||
|
||||
/** Largeur d'écran ; null avant le montage (SSR) : la CSS tranche alors seule. */
|
||||
export type Largeur = 'telephone' | 'tablette' | 'ordinateur' | null
|
||||
|
||||
const CLE_REPLI = 'aep_vue_fiches_liste_repliee'
|
||||
|
||||
export function useVueFiches(
|
||||
fiches: MaybeRefOrGetter<MiniFiche[]>,
|
||||
config: MaybeRefOrGetter<ConfigVueFiches>,
|
||||
) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cfg = computed(() => toValue(config))
|
||||
|
||||
// ── Lecture ──
|
||||
const brut = computed(() => lireQuery(route.query, cfg.value))
|
||||
|
||||
const q = ref(brut.value.q)
|
||||
let qEcrit = q.value
|
||||
let minuteurQ: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => brut.value.q, (v) => {
|
||||
if (v !== qEcrit) { q.value = v; qEcrit = v }
|
||||
})
|
||||
|
||||
const etat = computed<EtatFiches>(() => ({
|
||||
...brut.value,
|
||||
q: q.value,
|
||||
filtres: filtresValides(brut.value.filtres, cfg.value),
|
||||
}))
|
||||
|
||||
// ── Écriture ──
|
||||
function ecrire(partiel: Partial<EtatFiches>) {
|
||||
const nouvel = { ...etat.value, ...partiel }
|
||||
qEcrit = nouvel.q
|
||||
if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null }
|
||||
router.replace({ query: ecrireQuery(nouvel, cfg.value, route.query) })
|
||||
}
|
||||
|
||||
function setQ(v: string) {
|
||||
q.value = v
|
||||
if (minuteurQ) clearTimeout(minuteurQ)
|
||||
minuteurQ = setTimeout(() => { minuteurQ = null; ecrire({ q: v }) }, 250)
|
||||
}
|
||||
|
||||
function avecFiltre(dim: string, valeurs: string[]) {
|
||||
return { ...etat.value.filtres, [dim]: valeurs }
|
||||
}
|
||||
function toggle(dim: string, valeur: string) {
|
||||
const vals = etat.value.filtres[dim] ?? []
|
||||
ecrire({ filtres: avecFiltre(dim, vals.includes(valeur) ? vals.filter((v) => v !== valeur) : [...vals, valeur]) })
|
||||
}
|
||||
function ajouterFiltre(dim: string, valeur: string) {
|
||||
const vals = etat.value.filtres[dim] ?? []
|
||||
if (!vals.includes(valeur)) ecrire({ filtres: avecFiltre(dim, [...vals, valeur]) })
|
||||
}
|
||||
function effacerTout() {
|
||||
q.value = ''
|
||||
const vides: Record<string, string[]> = {}
|
||||
for (const d of cfg.value.dimensions) vides[d.cle] = []
|
||||
ecrire({ q: '', filtres: vides, sansAdresse: false })
|
||||
}
|
||||
const setTri = (tri: TriFiches) => ecrire({ tri })
|
||||
const setGroupe = (groupe: string | null) => ecrire({ groupe })
|
||||
const setSousVue = (sousVue: string) => ecrire({ sousVue })
|
||||
const setSansAdresse = (sansAdresse: boolean) => ecrire({ sansAdresse })
|
||||
|
||||
// ── Largeur et état affiché ──
|
||||
const largeur = ref<Largeur>(null)
|
||||
const listeRepliee = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const mq768 = window.matchMedia('(min-width: 768px)')
|
||||
const mq1024 = window.matchMedia('(min-width: 1024px)')
|
||||
const maj = () => {
|
||||
largeur.value = mq1024.matches ? 'ordinateur' : mq768.matches ? 'tablette' : 'telephone'
|
||||
}
|
||||
maj()
|
||||
mq768.addEventListener('change', maj)
|
||||
mq1024.addEventListener('change', maj)
|
||||
onBeforeUnmount(() => {
|
||||
mq768.removeEventListener('change', maj)
|
||||
mq1024.removeEventListener('change', maj)
|
||||
})
|
||||
try { listeRepliee.value = sessionStorage.getItem(CLE_REPLI) === '1' } catch {}
|
||||
})
|
||||
|
||||
/**
|
||||
* État rendu. `auto` avant le montage quand l'URL ne tranche pas (ou dit
|
||||
* `mixte`) : la CSS rend Mixte à partir de 1024 px et Fiches en dessous, sans
|
||||
* saut à l'hydratation. Mixte n'existe pas sous 1024 px : il se lit Fiches.
|
||||
*/
|
||||
const vueAffichee = computed<EtatVue | 'auto'>(() => {
|
||||
const etats = cfg.value.etats
|
||||
const v = etat.value.vue
|
||||
if (largeur.value === null) return v && v !== 'mixte' ? v : 'auto'
|
||||
const ordinateur = largeur.value === 'ordinateur'
|
||||
if (v === 'mixte' || v === null) {
|
||||
if (!ordinateur || !etats.includes('mixte')) return 'fiches'
|
||||
if (v === null && listeRepliee.value) return 'carte'
|
||||
return 'mixte'
|
||||
}
|
||||
return v
|
||||
})
|
||||
|
||||
function setVue(v: EtatVue) {
|
||||
if (largeur.value === 'ordinateur' && (v === 'mixte' || v === 'carte')) {
|
||||
listeRepliee.value = v === 'carte'
|
||||
try { sessionStorage.setItem(CLE_REPLI, listeRepliee.value ? '1' : '0') } catch {}
|
||||
}
|
||||
ecrire({ vue: v })
|
||||
}
|
||||
|
||||
// ── Résultat ──
|
||||
const sansAdresseIgnore = computed(() => ({ ...etat.value, sansAdresse: false }))
|
||||
const filtrees = computed(() => filtrerFiches(toValue(fiches), etat.value, cfg.value))
|
||||
const tri = computed(() => triEffectif(etat.value, cfg.value))
|
||||
const resultat = computed(() => trierFiches(filtrees.value, tri.value, etat.value))
|
||||
const groupeDef = computed(() => cfg.value.groupes.find((g) => g.cle === etat.value.groupe) ?? null)
|
||||
const groupes = computed(() => regrouper(resultat.value, groupeDef.value, cfg.value))
|
||||
const ordre = computed(() => ordreFiches(groupes.value))
|
||||
const compteurs = computed(() => compterEtiquettes(toValue(fiches), etat.value, cfg.value))
|
||||
const idsResultat = computed(() => new Set(resultat.value.map((f) => String(f.id))))
|
||||
const nbSansAdresse = computed(() =>
|
||||
filtrerFiches(toValue(fiches), sansAdresseIgnore.value, cfg.value).filter((f) => !f.geoloc).length,
|
||||
)
|
||||
const total = computed(() => toValue(fiches).length)
|
||||
const nbActifs = computed(() => nbFiltresActifs(etat.value))
|
||||
|
||||
function voirSansAdresse() {
|
||||
ecrire({ sansAdresse: true, vue: 'fiches' })
|
||||
}
|
||||
|
||||
/** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */
|
||||
const survolId = ref<string | number | null>(null)
|
||||
|
||||
return reactive({
|
||||
cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs,
|
||||
idsResultat, nbSansAdresse, total, nbActifs, survolId,
|
||||
setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue,
|
||||
setSansAdresse, voirSansAdresse,
|
||||
})
|
||||
}
|
||||
|
||||
export type VueFichesEtat = ReturnType<typeof useVueFiches>
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tests de la vue fiches (B11-M1) : logique pure de utils/vueFiches.ts et
|
||||
* adaptateurs de composables/useFicheAdapter.ts, chargés tels quels via jiti
|
||||
* (même patron que test-echelles-nav.mjs).
|
||||
* Usage : node scripts/test-vue-fiches.mjs
|
||||
*/
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const jiti = createJiti(import.meta.url)
|
||||
const V = await jiti.import(join(__dirname, '..', 'utils', 'vueFiches.ts'))
|
||||
const A = await jiti.import(join(__dirname, '..', 'composables', 'useFicheAdapter.ts'))
|
||||
|
||||
let n = 0
|
||||
let fail = 0
|
||||
function ok(cond, libelle, detail = '') {
|
||||
n++
|
||||
if (!cond) fail++
|
||||
console.log(`${cond ? 'OK ' : 'FAIL'} ${libelle}${!cond && detail ? ` → ${detail}` : ''}`)
|
||||
}
|
||||
const trie = (o) => (o && typeof o === "object" && !Array.isArray(o) ? Object.fromEntries(Object.keys(o).sort().map((k) => [k, trie(o[k])])) : o)
|
||||
const eq = (a, b) => JSON.stringify(trie(a)) === JSON.stringify(trie(b))
|
||||
|
||||
// ── Données : configuration Entraide, fiches adaptées depuis de vrais Org ──
|
||||
const FONCTIONS = ['Juridique', 'Technique', 'Formation', 'Santé mentale']
|
||||
const cfg = {
|
||||
dimensions: [
|
||||
{ cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: FONCTIONS.map((v) => ({ valeur: v, libelle: v })) },
|
||||
{ cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: ['National', 'Régional', 'Local'].map((v) => ({ valeur: v, libelle: v })) },
|
||||
],
|
||||
tris: ['pertinence', 'nom', 'recent'],
|
||||
groupes: [
|
||||
{ cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' },
|
||||
{ cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' },
|
||||
{ cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire' },
|
||||
],
|
||||
etats: ['carte', 'mixte', 'fiches'],
|
||||
sousVues: { parametre: 'mode', defaut: 'metropole', valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }] },
|
||||
}
|
||||
|
||||
const orgs = [
|
||||
{ Id: 1, nom: 'CNOA', echelle: 'National', tags_fonction: 'Juridique', localisation_ville: 'Paris', latitude: 48.8, longitude: 2.3, description: "Ordre des architectes. Deuxième phrase." },
|
||||
{ Id: 2, nom: 'Collectif Fil', echelle: 'Régional', tags_fonction: 'Formation,Juridique', localisation_ville: 'Nantes', latitude: 47.2, longitude: -1.5, description: 'Recherche-action en architecture.' },
|
||||
{ Id: 3, nom: 'Écoute Archi', echelle: 'Local', tags_fonction: 'Santé mentale', localisation_ville: 'Fort-de-France', territoire: 'Martinique', latitude: null, longitude: null, description: 'Soutien psychologique.' },
|
||||
{ Id: 4, nom: 'Bureau Technique', echelle: 'Régional', tags_fonction: 'Technique', localisation_ville: 'Lyon', latitude: 45.7, longitude: 4.8, submitted_at: '2026-09-01T10:00:00Z' },
|
||||
{ Id: 5, nom: 'Atelier sans tag', localisation_ville: 'Rennes', latitude: 48.1, longitude: -1.6 },
|
||||
]
|
||||
const fiches = orgs.map(A.toMini)
|
||||
|
||||
const etat0 = (over = {}) => ({ ...V.lireQuery({}, cfg), ...over })
|
||||
const ids = (fs) => fs.map((f) => f.id)
|
||||
|
||||
// ── Adaptateurs (M2) ──
|
||||
ok(eq(fiches[1].etiquettes, { fonctions: ['Formation', 'Juridique'], echelle: ['Régional'], territoire: ['Métropole'] }), 'org → etiquettes par dimension (fonctions complètes, échelle, territoire)')
|
||||
ok(fiches[2].etiquettes.territoire[0] === 'Martinique' && fiches[2].geoloc === false && fiches[2].coords === undefined, 'org DOM sans coordonnées → territoire Martinique, geoloc false, coords absentes')
|
||||
ok(eq(fiches[0].coords, [48.8, 2.3]) && fiches[0].resume === 'Ordre des architectes.', 'org → coords et résumé (première phrase) inchangé')
|
||||
ok(eq(fiches[1].chips, ['Formation', 'Juridique']) && fiches[1].chipsDimension === 'fonctions' && fiches[1].echelle === 'Régional', 'org → chips et echelle d’affichage inchangés')
|
||||
const structure = A.toMini({
|
||||
id: 'f1-rotor', nom: 'Rotor', url: '', pays: 'BE', ville: 'Bruxelles', famille_principale: 1, familles_secondaires: [3],
|
||||
hashtags: ['#reemploi-structurel', '#plateforme-materiaux'], type_principal: 'Coopérative',
|
||||
badges: { centre_ressources: true, mouvement_manifeste: false, contre_pouvoir_spatial: false, f6_recherche_politique: true },
|
||||
description_courte: 'Réemploi des matériaux de construction.', description_longue: '', pensees: [], sources: [], already_in_v1: false, eligible_v2: true,
|
||||
latitude: 50.8, longitude: 4.3,
|
||||
})
|
||||
ok(eq(structure.etiquettes, { tags: ['reemploi-structurel', 'plateforme-materiaux'], famille: ['1', '3', '6'], pays: ['BE'] }), 'structure → tags sans #, familles principale + secondaires + 6 (badge), pays')
|
||||
ok(eq(structure.chips, ['#reemploi-structurel', '#plateforme-materiaux']) && structure.chipsDimension === 'tags', 'structure → chips d’affichage avec # inchangés')
|
||||
|
||||
// ── URL ↔ état ──
|
||||
const q1 = { vue: 'fiches', q: 'ordre', fonctions: 'Juridique,Formation', echelle: 'Régional', tri: 'nom', groupe: 'fonction', mode: 'outremer', adresse: 'sans' }
|
||||
const e1 = V.lireQuery(q1, cfg)
|
||||
ok(e1.vue === 'fiches' && e1.q === 'ordre' && eq(e1.filtres, { fonctions: ['Juridique', 'Formation'], echelle: ['Régional'] }) && e1.tri === 'nom' && e1.groupe === 'fonction' && e1.sousVue === 'outremer' && e1.sansAdresse, 'lecture : tous les paramètres de la SPEC §5')
|
||||
ok(eq(V.ecrireQuery(e1, cfg), q1), 'aller-retour URL → état → URL identique')
|
||||
const e2 = etat0({ vue: 'mixte', filtres: { fonctions: ['Technique'], echelle: [] }, groupe: 'echelle' })
|
||||
ok(eq(V.lireQuery(V.ecrireQuery(e2, cfg), cfg), e2), 'aller-retour état → URL → état identique')
|
||||
ok(eq(V.ecrireQuery(etat0(), cfg), {}), 'état par défaut → URL vide (sous-vue par défaut non écrite)')
|
||||
ok(eq(V.ecrireQuery(etat0({ q: 'x' }), cfg, { territoire: 'Guyane', random: '1', vue: 'carte', q: 'ancien' }), { territoire: 'Guyane', random: '1', q: 'x' }), 'paramètres non gérés (territoire, random) conservés, gérés remplacés')
|
||||
|
||||
// Rétrocompatibilité et paramètres invalides
|
||||
ok(V.lireQuery({ vue: 'fiches' }, cfg).vue === 'fiches', 'rétrocompat : ?vue=fiches (lien B3 diffusé) → état Fiches')
|
||||
ok(V.lireQuery({ mode: 'outremer' }, cfg).sousVue === 'outremer', 'rétrocompat : ?mode=outremer → sous-vue Outre-mer')
|
||||
ok(V.lireQuery({}, cfg).vue === null, 'sans vue= → null (défaut par largeur)')
|
||||
const inv = V.lireQuery({ vue: 'plein-ecran', tri: 'distance', groupe: 'couleur', mode: 'lune', adresse: 'oui', zzz: '1' }, cfg)
|
||||
ok(inv.vue === null && inv.tri === null && inv.groupe === null && inv.sousVue === 'metropole' && inv.sansAdresse === false, 'paramètres invalides ignorés sans erreur (vue, tri, groupe, mode, adresse)')
|
||||
ok(eq(V.filtresValides({ fonctions: ['Juridique', 'Astrologie'], echelle: ['Mondial'] }, cfg), { fonctions: ['Juridique'], echelle: [] }), 'valeurs de filtre inconnues ignorées')
|
||||
const cfgVide = { ...cfg, dimensions: [{ ...cfg.dimensions[0], valeurs: [] }] }
|
||||
ok(eq(V.filtresValides({ fonctions: ['Astrologie'] }, cfgVide), { fonctions: ['Astrologie'] }), 'dimension pas encore chargée : valeurs gardées (lien partagé non perdu)')
|
||||
ok(eq(V.lireQuery({ fonctions: ['Juridique,Formation', 'x'], q: ['a', 'b'] }, cfg).filtres.fonctions, ['Juridique', 'Formation']), 'paramètre répété : seule la première occurrence est lue')
|
||||
ok(eq(V.lireQuery({ fonctions: ' Juridique ,,Juridique' }, cfg).filtres.fonctions, ['Juridique']), 'espaces, vides et doublons nettoyés')
|
||||
|
||||
// ── Filtrage : OU dans une dimension, ET entre ──
|
||||
const filtre = (over) => ids(V.filtrerFiches(fiches, etat0(over), cfg))
|
||||
ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: [] } }), [1, 2, 4]), 'OU dans une dimension : Juridique OU Technique')
|
||||
ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: ['Régional'] } }), [2, 4]), 'ET entre dimensions : (Juridique OU Technique) ET Régional')
|
||||
ok(eq(filtre({ filtres: { fonctions: [], echelle: ['National', 'Local'] } }), [1, 3]), 'OU sur la dimension secondaire')
|
||||
ok(eq(filtre({ q: 'ecoute' }), [3]), 'recherche insensible aux accents et à la casse (« ecoute » → Écoute)')
|
||||
ok(eq(filtre({ q: 'NANTES' }), [2]), 'recherche sur la ville')
|
||||
ok(eq(filtre({ q: 'psychologique' }), [3]), 'recherche sur le résumé')
|
||||
ok(eq(filtre({ sansAdresse: true }), [3]), 'adresse=sans → seulement les fiches non localisées')
|
||||
|
||||
// ── Compteurs ──
|
||||
const c = V.compterEtiquettes(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: ['Régional'] } }), cfg)
|
||||
ok(c.fonctions.Juridique === 1 && c.fonctions.Technique === 1 && c.fonctions.Formation === 1 && c.fonctions['Santé mentale'] === 0, 'compteurs fonction : dans le résultat des AUTRES dimensions (Régional)', JSON.stringify(c.fonctions))
|
||||
ok(c.echelle.National === 1 && c.echelle.Régional === 1 && c.echelle.Local === 0, 'compteurs échelle : dans le résultat des autres dimensions (Juridique)', JSON.stringify(c.echelle))
|
||||
const cq = V.compterEtiquettes(fiches, etat0({ q: 'paris' }), cfg)
|
||||
ok(cq.fonctions.Juridique === 1 && cq.fonctions.Formation === 0, 'compteurs : la recherche s’applique')
|
||||
ok(V.nbFiltresActifs(etat0({ filtres: { fonctions: ['A', 'B'], echelle: ['C'] }, sansAdresse: true })) === 4, 'nombre de filtres actifs (pour « Filtrer · n »)')
|
||||
|
||||
// ── Tri ──
|
||||
ok(V.triEffectif(etat0(), cfg) === 'nom', 'tri par défaut sans filtre : nom')
|
||||
ok(V.triEffectif(etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'pertinence', 'tri par défaut avec filtre : pertinence')
|
||||
ok(V.triEffectif(etat0({ tri: 'recent', filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'recent', 'tri explicite prioritaire')
|
||||
ok(eq(ids(V.trierFiches(fiches, 'nom', etat0())), [5, 4, 1, 2, 3]), 'tri nom A → Z (« Écoute » rangé à E)')
|
||||
const ePert = etat0({ filtres: { fonctions: ['Juridique', 'Formation'], echelle: [] } })
|
||||
ok(eq(ids(V.trierFiches(V.filtrerFiches(fiches, ePert, cfg), 'pertinence', ePert)), [2, 1]), 'pertinence : la fiche qui coche le plus d’étiquettes d’abord')
|
||||
ok(eq(ids(V.trierFiches(fiches, 'recent', etat0())), [4, 5, 3, 2, 1]), 'récent : date d’abord, puis Id décroissant')
|
||||
ok(V.triEffectif(etat0(), { ...cfg, tris: ['pertinence', 'nom'] }) === 'nom', 'Réseaux AEP (sans récent) : défaut nom')
|
||||
|
||||
// ── Regroupement ──
|
||||
const g = V.regrouper(V.trierFiches(fiches, 'nom', etat0()), cfg.groupes[0], cfg)
|
||||
ok(eq(g.map((x) => `${x.libelle}:${ids(x.fiches).join('+')}`), ['Juridique:1', 'Technique:4', 'Formation:2', 'Santé mentale:3', 'Sans fonction:5']), 'groupe fonction : première étiquette, ordre de la dimension, « sans » en dernier', JSON.stringify(g.map((x) => x.libelle)))
|
||||
ok(g.reduce((s, x) => s + x.fiches.length, 0) === fiches.length, 'regroupement : chaque fiche une seule fois (compteurs justes)')
|
||||
const gt = V.regrouper(fiches, cfg.groupes[2], cfg)
|
||||
ok(eq(gt.map((x) => x.libelle), ['Martinique', 'Métropole']), 'groupe territoire (dimension sans chips) : ordre A → Z')
|
||||
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)')
|
||||
|
||||
console.log(`\n${n - fail}/${n}`)
|
||||
process.exit(fail ? 1 : 0)
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* vueFiches — B11-M1. Logique pure de la vue fiches (SPEC-vue-fiches §2-§5) :
|
||||
* lecture et écriture de l'URL, filtrage, compteurs, tri, regroupement.
|
||||
* Aucune dépendance à Vue ni au routeur : `composables/useVueFiches.ts`
|
||||
* branche ces fonctions sur la route, `scripts/test-vue-fiches.mjs` les teste.
|
||||
*
|
||||
* Grammaire des filtres : OU dans une dimension, ET entre dimensions.
|
||||
* L'URL est la source de vérité ; un paramètre inconnu ou invalide est ignoré.
|
||||
*/
|
||||
import type { MiniFiche } from '../composables/useFicheAdapter'
|
||||
|
||||
export type EtatVue = 'carte' | 'mixte' | 'fiches'
|
||||
export type TriFiches = 'pertinence' | 'nom' | 'recent'
|
||||
|
||||
export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches']
|
||||
export const LIBELLES_TRI: Record<TriFiches, string> = {
|
||||
pertinence: 'Pertinence',
|
||||
nom: 'A → Z',
|
||||
recent: 'Récentes',
|
||||
}
|
||||
|
||||
export interface ValeurFiltre {
|
||||
valeur: string
|
||||
libelle: string
|
||||
}
|
||||
|
||||
export interface DimensionFiltre {
|
||||
/** Paramètre d'URL ET clé de `MiniFiche.etiquettes`. */
|
||||
cle: string
|
||||
libelle: string
|
||||
valeurs: ValeurFiltre[]
|
||||
variante: 'fonction' | 'echelle' | 'posture'
|
||||
}
|
||||
|
||||
export interface GroupeDef {
|
||||
/** Valeur de `groupe=` dans l'URL. */
|
||||
cle: string
|
||||
libelle: string
|
||||
/** Clé de `MiniFiche.etiquettes` : la fiche va sous sa PREMIÈRE étiquette. */
|
||||
dimension: string
|
||||
/** En-tête des fiches qui n'ont aucune étiquette dans cette dimension. */
|
||||
sans: string
|
||||
/** Libellés et ordre des groupes, sinon ceux de la dimension de filtre, sinon A → Z. */
|
||||
valeurs?: ValeurFiltre[]
|
||||
}
|
||||
|
||||
export interface SousVuesCarte {
|
||||
/** Paramètre d'URL : `mode` sur `/` (existant), `carte` sur `/agences`. */
|
||||
parametre: string
|
||||
valeurs: ValeurFiltre[]
|
||||
defaut: string
|
||||
}
|
||||
|
||||
export interface ConfigVueFiches {
|
||||
dimensions: DimensionFiltre[]
|
||||
tris: TriFiches[]
|
||||
groupes: GroupeDef[]
|
||||
etats: EtatVue[]
|
||||
sousVues?: SousVuesCarte
|
||||
}
|
||||
|
||||
export interface EtatFiches {
|
||||
/** null = pas de `vue=` dans l'URL : défaut par largeur (Mixte sur ordinateur, Fiches sur téléphone). */
|
||||
vue: EtatVue | null
|
||||
q: string
|
||||
filtres: Record<string, string[]>
|
||||
/** `adresse=sans` : seulement les fiches sans coordonnées (lien « N sans adresse »). */
|
||||
sansAdresse: boolean
|
||||
/** null = défaut automatique (pertinence dès qu'un filtre est actif, sinon nom). */
|
||||
tri: TriFiches | null
|
||||
/** null = aucun regroupement. */
|
||||
groupe: string | null
|
||||
sousVue: string
|
||||
}
|
||||
|
||||
export interface GroupeRendu {
|
||||
cle: string
|
||||
libelle: string
|
||||
fiches: MiniFiche[]
|
||||
}
|
||||
|
||||
type Query = Record<string, unknown>
|
||||
|
||||
function premiere(v: unknown): string {
|
||||
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function liste(v: unknown): string[] {
|
||||
const brut = premiere(v)
|
||||
return [...new Set(brut.split(',').map((s) => s.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
/** Minuscules, sans accents : « Régional » et « regional » se valent. */
|
||||
export function normaliser(s: string): string {
|
||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
// ── URL ↔ état ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches {
|
||||
const vueBrute = premiere(query.vue) as EtatVue
|
||||
const triBrut = premiere(query.tri) as TriFiches
|
||||
const groupeBrut = premiere(query.groupe)
|
||||
const sv = cfg.sousVues
|
||||
const svBrute = sv ? premiere(query[sv.parametre]) : ''
|
||||
|
||||
const filtres: Record<string, string[]> = {}
|
||||
for (const d of cfg.dimensions) filtres[d.cle] = liste(query[d.cle])
|
||||
|
||||
return {
|
||||
vue: cfg.etats.includes(vueBrute) ? vueBrute : null,
|
||||
q: premiere(query.q),
|
||||
filtres,
|
||||
sansAdresse: premiere(query.adresse) === 'sans',
|
||||
tri: cfg.tris.includes(triBrut) ? triBrut : null,
|
||||
groupe: cfg.groupes.some((g) => g.cle === groupeBrut) ? groupeBrut : null,
|
||||
sousVue: sv && sv.valeurs.some((v) => v.valeur === svBrute) ? svBrute : (sv?.defaut ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
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)])
|
||||
if (cfg.sousVues) geres.add(cfg.sousVues.parametre)
|
||||
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(base)) {
|
||||
if (!geres.has(k) && typeof premiere(v) === 'string' && premiere(v) !== '') out[k] = premiere(v)
|
||||
}
|
||||
if (etat.vue) out.vue = etat.vue
|
||||
if (etat.q.trim()) out.q = etat.q
|
||||
for (const d of cfg.dimensions) {
|
||||
const vals = etat.filtres[d.cle] ?? []
|
||||
if (vals.length) out[d.cle] = vals.join(',')
|
||||
}
|
||||
if (etat.sansAdresse) out.adresse = 'sans'
|
||||
if (etat.tri) out.tri = etat.tri
|
||||
if (etat.groupe) out.groupe = etat.groupe
|
||||
if (cfg.sousVues && etat.sousVue && etat.sousVue !== cfg.sousVues.defaut) {
|
||||
out[cfg.sousVues.parametre] = etat.sousVue
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Ne garde que les valeurs connues de chaque dimension. Une dimension dont la
|
||||
* liste de valeurs est encore vide (données pas chargées) garde tout : le
|
||||
* filtre s'appliquera dès l'arrivée des données, sans perdre le lien partagé.
|
||||
*/
|
||||
export function filtresValides(filtres: Record<string, string[]>, cfg: ConfigVueFiches): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const d of cfg.dimensions) {
|
||||
const vals = filtres[d.cle] ?? []
|
||||
out[d.cle] = d.valeurs.length ? vals.filter((v) => d.valeurs.some((x) => x.valeur === v)) : vals
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function nbFiltresActifs(etat: EtatFiches): number {
|
||||
return Object.values(etat.filtres).reduce((n, v) => n + v.length, 0) + (etat.sansAdresse ? 1 : 0)
|
||||
}
|
||||
|
||||
// ── Filtrage ───────────────────────────────────────────────────────────────
|
||||
|
||||
function porte(f: MiniFiche, dimension: string, valeur: string): boolean {
|
||||
return (f.etiquettes?.[dimension] ?? []).includes(valeur)
|
||||
}
|
||||
|
||||
export function correspondRecherche(f: MiniFiche, q: string): boolean {
|
||||
const n = normaliser(q.trim())
|
||||
if (!n) return true
|
||||
return [f.nom, f.ville, f.resume, f.texte].some((t) => !!t && normaliser(t).includes(n))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiches qui passent la recherche, `adresse=sans` et les filtres de toutes les
|
||||
* dimensions sauf `ignorer` (sert aux compteurs). OU dans une dimension, ET entre.
|
||||
*/
|
||||
export function filtrerFiches(
|
||||
fiches: MiniFiche[],
|
||||
etat: EtatFiches,
|
||||
cfg: ConfigVueFiches,
|
||||
ignorer?: string,
|
||||
): MiniFiche[] {
|
||||
return fiches.filter((f) => {
|
||||
if (etat.sansAdresse && f.geoloc) return false
|
||||
if (!correspondRecherche(f, etat.q)) return false
|
||||
for (const d of cfg.dimensions) {
|
||||
if (d.cle === ignorer) continue
|
||||
const vals = etat.filtres[d.cle] ?? []
|
||||
if (vals.length && !vals.some((v) => porte(f, d.cle, v))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compteur de chaque chip : fiches qui portent cette étiquette parmi celles que
|
||||
* laissent passer la recherche et les AUTRES dimensions. Une chip active affiche
|
||||
* donc combien de fiches elle apporte, une chip inactive combien on en aurait
|
||||
* en la cochant seule dans sa dimension ; 0 = chip grisée.
|
||||
*/
|
||||
export function compterEtiquettes(
|
||||
fiches: MiniFiche[],
|
||||
etat: EtatFiches,
|
||||
cfg: ConfigVueFiches,
|
||||
): Record<string, Record<string, number>> {
|
||||
const out: Record<string, Record<string, number>> = {}
|
||||
for (const d of cfg.dimensions) {
|
||||
const base = filtrerFiches(fiches, etat, cfg, d.cle)
|
||||
const c: Record<string, number> = {}
|
||||
for (const v of d.valeurs) c[v.valeur] = 0
|
||||
for (const f of base) for (const v of f.etiquettes?.[d.cle] ?? []) if (v in c) c[v]++
|
||||
out[d.cle] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Tri ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches {
|
||||
if (etat.tri) return etat.tri
|
||||
const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0)
|
||||
if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence'
|
||||
return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom'
|
||||
}
|
||||
|
||||
/** Nombre d'étiquettes cochées que porte la fiche. */
|
||||
export function pertinence(f: MiniFiche, etat: EtatFiches): number {
|
||||
let s = 0
|
||||
for (const [dim, vals] of Object.entries(etat.filtres)) for (const v of vals) if (porte(f, dim, v)) s++
|
||||
return s
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('fr', { sensitivity: 'base', numeric: true })
|
||||
|
||||
function parNom(a: MiniFiche, b: MiniFiche): number {
|
||||
return collator.compare(a.nom ?? '', b.nom ?? '')
|
||||
}
|
||||
|
||||
function idNum(f: MiniFiche): number {
|
||||
const n = Number(f.id)
|
||||
return Number.isFinite(n) ? n : -Infinity
|
||||
}
|
||||
|
||||
export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] {
|
||||
const copie = [...fiches]
|
||||
if (tri === 'nom') return copie.sort(parNom)
|
||||
if (tri === 'pertinence') {
|
||||
const score = new Map(copie.map((f) => [f, pertinence(f, etat)]))
|
||||
return copie.sort((a, b) => score.get(b)! - score.get(a)! || parNom(a, b))
|
||||
}
|
||||
// récent : date décroissante, puis Id décroissant (Entraide : Id = ordre d'arrivée)
|
||||
return copie.sort((a, b) => {
|
||||
if (a.date && b.date && a.date !== b.date) return a.date < b.date ? 1 : -1
|
||||
if (!!a.date !== !!b.date) return a.date ? -1 : 1
|
||||
return idNum(b) - idNum(a) || parNom(a, b)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Regroupement ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une fiche va sous sa PREMIÈRE étiquette de la dimension (sinon le compteur
|
||||
* de groupe ment). Ordre des groupes : celui de `def.valeurs`, sinon de la
|
||||
* dimension de filtre, sinon A → Z ; « sans » en dernier. L'ordre des fiches
|
||||
* dans chaque groupe est celui reçu (déjà trié).
|
||||
*/
|
||||
export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: ConfigVueFiches): GroupeRendu[] {
|
||||
if (!def) return [{ cle: '', libelle: '', fiches }]
|
||||
const ref = def.valeurs ?? cfg.dimensions.find((d) => d.cle === def.dimension)?.valeurs ?? []
|
||||
const libelle = (v: string) => ref.find((x) => x.valeur === v)?.libelle ?? v
|
||||
|
||||
const parCle = new Map<string, MiniFiche[]>()
|
||||
for (const f of fiches) {
|
||||
const cle = f.etiquettes?.[def.dimension]?.[0] ?? ''
|
||||
if (!parCle.has(cle)) parCle.set(cle, [])
|
||||
parCle.get(cle)!.push(f)
|
||||
}
|
||||
const rang = (cle: string) => {
|
||||
if (cle === '') return Number.MAX_SAFE_INTEGER
|
||||
const i = ref.findIndex((x) => x.valeur === cle)
|
||||
return i === -1 ? ref.length : i
|
||||
}
|
||||
return [...parCle.keys()]
|
||||
.sort((a, b) => rang(a) - rang(b) || collator.compare(libelle(a), libelle(b)))
|
||||
.map((cle) => ({ cle, libelle: cle === '' ? def.sans : libelle(cle), fiches: parCle.get(cle)! }))
|
||||
}
|
||||
|
||||
/** Ordre de lecture courant (filtres + tri + regroupement) : le fiche à fiche de B12 le suit. */
|
||||
export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] {
|
||||
return groupes.flatMap((g) => g.fiches)
|
||||
}
|
||||
Reference in New Issue
Block a user