feat(vue-fiches): tri ordre d'origine, triDefaut, libellés de vue et adaptateur mot (B6-M2)
Extensions optionnelles du composant partagé, sans effet sur / et /agences : tri 'ordre' (ordre du tableau d'entrée) avec config.triDefaut et libellesTri, prop libellesVue (Graphe au lieu de Carte, Voir dans le graphe), adaptateur toMiniMot (kind 'mot'). 22 cas ajoutés à test-vue-fiches (80/80). Co-Authored-By: Claude Sonnet <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
This commit is contained in:
co-authored by
Claude Sonnet
parent
68d3b080d7
commit
11f24c7925
@@ -101,6 +101,8 @@ Contenus de détail en place : `/` → `FicheDetail` (`compact`, `etiquettes-cli
|
||||
3. Le slot `#carte`, et le slot `#detail` (contenu de la fiche ; une page sans contenu de détail affiche seulement les actions). Les épingles appellent `vf.ouvrirFiche(id)`.
|
||||
4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle.
|
||||
|
||||
**Troisième page en place : `/outils?tab=partir-de-soi` (B6-M2)** — sept fiches, une par mot de la slide 17 (`components/OutilsPartirDeSoi.vue`, adaptateur `toMiniMot`). Trois extensions optionnelles du composant, sans effet sur `/` et `/agences` : tri `ordre` (ordre du tableau d'entrée) avec `config.triDefaut` (tri sans filtre ; la pertinence reste prioritaire dès qu'un filtre est actif) et `config.libellesTri` ; prop `libelles-vue` (`{ carte: 'Graphe' }` : le bouton, et « Voir dans le graphe » au lieu de « Voir sur la carte » ; l'URL garde `vue=carte`). Le slot `#carte` y est `GrapheMots.vue`, un SVG statique (nœud central, mots en couronne, ressources en feuilles) dans le vocabulaire du motif maillage. La page vit dans un onglet : `tab=` est un paramètre que la vue ne gère pas, donc recopié à chaque écriture.
|
||||
|
||||
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
|
||||
|
||||
## Intention d'abord
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
:class="`vf-seg__btn--${e}`"
|
||||
:aria-pressed="vf.vueAffichee === e"
|
||||
@click="vf.setVue(e)"
|
||||
>{{ LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
|
||||
>{{ libellesVue?.[e] ?? LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
|
||||
</div>
|
||||
|
||||
<label class="vf-recherche">
|
||||
@@ -79,7 +79,7 @@
|
||||
<label class="vf-select vf-select--tri">
|
||||
<span>Tri</span>
|
||||
<select :value="vf.tri" @change="vf.setTri(($event.target as HTMLSelectElement).value as TriFiches)">
|
||||
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ LIBELLES_TRI[t] }}</option>
|
||||
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ vf.cfg.libellesTri?.[t] ?? LIBELLES_TRI[t] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
<template v-if="vf.ficheOuverte">
|
||||
<div class="vf-detail__actions">
|
||||
<a v-if="lienPageFiche" :href="lienPageFiche" class="vf-lien">Ouvrir la page de la fiche</a>
|
||||
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">Voir sur la carte</button>
|
||||
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">{{ libelleVoirCarte }}</button>
|
||||
<button type="button" class="vf-lien" @click="copierLien">Copier le lien</button>
|
||||
<span class="vf-detail__statut" role="status">{{ statutLien }}</span>
|
||||
</div>
|
||||
@@ -311,6 +311,8 @@ const props = defineProps<{
|
||||
selectedId?: string | number | null
|
||||
/** Page propre de la fiche (`/fiche/:id` pour l'Entraide) ; null = pas de page, le lien partagé est l'URL avec `fiche=`. */
|
||||
lienPage?: (f: MiniFiche) => string | null
|
||||
/** Libellés des trois états quand « Carte » n'est pas le mot juste (ex. { carte: 'Graphe' }). Le paramètre d'URL reste `vue=carte`. */
|
||||
libellesVue?: Partial<Record<EtatVue, string>>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -319,6 +321,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const LIBELLES_VUE: Record<EtatVue, string> = { carte: 'Carte', mixte: 'Mixte', fiches: 'Fiches' }
|
||||
/** « Voir sur la carte » suit le mot choisi pour l'état Carte : « Voir dans le graphe ». */
|
||||
const libelleVoirCarte = computed(() => (props.libellesVue?.carte ? `Voir dans le ${props.libellesVue.carte.toLowerCase()}` : 'Voir sur la carte'))
|
||||
const etatsBoutons = computed(() => (['carte', 'mixte', 'fiches'] as EtatVue[]).filter((e) => props.vf.cfg.etats.includes(e)))
|
||||
|
||||
// ── Pastilles actives (état Carte, et téléphone en Fiches) ──
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface MiniFiche {
|
||||
chipsDimension?: string
|
||||
echelle?: string
|
||||
geoloc: boolean
|
||||
kind: 'org' | 'structure'
|
||||
kind: 'org' | 'structure' | 'mot'
|
||||
/** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */
|
||||
etiquettes?: Record<string, string[]>
|
||||
coords?: [number, number]
|
||||
@@ -134,6 +134,65 @@ export function toMini(source: Org | StructureV2): MiniFiche {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Partir de soi (B6-M2) : un mot de la slide 17 = une fiche ─────────────
|
||||
|
||||
/** Une fiche de `public/data/outils.json` → `partir_de_soi.fiches[]`. */
|
||||
export interface FicheMot {
|
||||
id: string
|
||||
mot: string
|
||||
resume: string
|
||||
vide?: string | null
|
||||
}
|
||||
|
||||
/** Une ressource de `partir_de_soi.ressources[]`, reliée à ses mots par `fiches`. */
|
||||
export interface RessourceMot {
|
||||
id: string
|
||||
titre: string
|
||||
format: string
|
||||
acces: string
|
||||
pourquoi?: string
|
||||
fiches: string[]
|
||||
}
|
||||
|
||||
/** « don libre » → `don-libre` : la forme d'une valeur de filtre dans l'URL (`acces=don-libre`). */
|
||||
export function slugEtiquette(s: string): string {
|
||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/** Valeurs distinctes d'un champ des ressources, dans l'ordre de première apparition (ordre stable des chips). */
|
||||
export function valeursRessources(ressources: RessourceMot[], champ: 'format' | 'acces'): { valeur: string; libelle: string }[] {
|
||||
const vus = new Map<string, string>()
|
||||
for (const r of ressources) {
|
||||
const l = r[champ]
|
||||
if (l && !vus.has(slugEtiquette(l))) vus.set(slugEtiquette(l), l)
|
||||
}
|
||||
return [...vus].map(([valeur, libelle]) => ({ valeur, libelle }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Un mot de la conférence → `MiniFiche`. `ressources` = celles qui portent ce mot.
|
||||
* Pas de carte géographique : `geoloc: true` (aucune fiche « sans adresse »).
|
||||
* La recherche couvre aussi les titres et les « pourquoi » des ressources.
|
||||
*/
|
||||
export function toMiniMot(fiche: FicheMot, ressources: RessourceMot[]): MiniFiche {
|
||||
const formats = [...new Set(ressources.map((r) => r.format).filter(Boolean))]
|
||||
const acces = [...new Set(ressources.map((r) => r.acces).filter(Boolean))]
|
||||
return {
|
||||
id: fiche.id,
|
||||
nom: fiche.mot,
|
||||
resume: buildResume([fiche.resume, fiche.vide]),
|
||||
chips: formats.slice(0, 3),
|
||||
chipsDimension: 'format',
|
||||
geoloc: true,
|
||||
kind: 'mot',
|
||||
etiquettes: {
|
||||
format: formats.map(slugEtiquette),
|
||||
acces: acces.map(slugEtiquette),
|
||||
},
|
||||
texte: [fiche.resume, ...ressources.flatMap((r) => [r.titre, r.pourquoi])].filter(Boolean).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
export function useFicheAdapter() {
|
||||
return { toMini }
|
||||
return { toMini, toMiniMot }
|
||||
}
|
||||
|
||||
@@ -186,5 +186,62 @@ 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)')
|
||||
|
||||
// ── Partir de soi : sept mots, ordre de la slide (B6-M2) ──
|
||||
const MOTS = [
|
||||
{ id: 'quete-de-sens', mot: 'quête de sens', resume: 'Bifurquer, donner du sens à son métier.' },
|
||||
{ id: 'gagner-sa-vie', mot: 'gagner sa vie', resume: 'Créer son activité et en vivre.', vide: 'Rien encore.' },
|
||||
{ id: 'sante-mentale', mot: 'santé mentale', resume: 'Repérer, comprendre, se faire accompagner.' },
|
||||
{ id: 'naviguer-avec-soi', mot: 'naviguer avec soi', resume: 'Méditation et pratiques d’introspection.' },
|
||||
]
|
||||
const RESS = [
|
||||
{ id: 'film', titre: 'En quête de sens', format: 'film', acces: 'don libre', pourquoi: 'Le film nommé dans le deck.', fiches: ['quete-de-sens'] },
|
||||
{ id: 'ofqa', titre: 'OFQA', format: 'vidéo', acces: 'libre', pourquoi: 'Diplômés qui ont bifurqué.', fiches: ['quete-de-sens'] },
|
||||
{ id: 'psc', titre: 'Premiers secours en santé mentale', format: 'formation', acces: 'financé selon région', pourquoi: 'Repérer les signes.', fiches: ['sante-mentale'] },
|
||||
{ id: 'vipassana', titre: 'Vipassana', format: 'retraite', acces: 'don libre', pourquoi: 'Dix jours de silence.', fiches: ['naviguer-avec-soi'] },
|
||||
{ id: 'insight', titre: 'Insight Timer', format: 'appli', acces: 'gratuit avec inscription', pourquoi: 'Méditations guidées.', fiches: ['naviguer-avec-soi', 'sante-mentale'] },
|
||||
]
|
||||
const parMot = (id) => RESS.filter((r) => r.fiches.includes(id))
|
||||
const mots = MOTS.map((m) => A.toMiniMot(m, parMot(m.id)))
|
||||
const cfgMots = {
|
||||
dimensions: [
|
||||
{ cle: 'format', libelle: 'Format', variante: 'fonction', valeurs: A.valeursRessources(RESS, 'format') },
|
||||
{ cle: 'acces', libelle: 'Accès', variante: 'echelle', valeurs: A.valeursRessources(RESS, 'acces') },
|
||||
],
|
||||
tris: ['ordre', 'pertinence', 'nom'],
|
||||
triDefaut: 'ordre',
|
||||
libellesTri: { ordre: 'Ordre de la slide' },
|
||||
groupes: [],
|
||||
etats: ['carte', 'mixte', 'fiches'],
|
||||
}
|
||||
const etatM = (query = {}, over = {}) => ({ ...V.lireQuery(query, cfgMots), ...over })
|
||||
const sansDefaut = { ...cfgMots, triDefaut: undefined, tris: ['pertinence', 'nom'] }
|
||||
|
||||
ok(mots[0].kind === 'mot' && mots[0].nom === 'quête de sens' && eq(mots[0].chips, ['film', 'vidéo']) && mots[0].chipsDimension === 'format' && mots[0].geoloc === true, 'mot → kind mot, nom = le mot, chips = formats, geoloc true (pas de carte géographique)')
|
||||
ok(eq(mots[0].etiquettes, { format: ['film', 'video'], acces: ['don-libre', 'libre'] }), 'mot → étiquettes en slug (sans accent, sans espace) pour l’URL', JSON.stringify(mots[0].etiquettes))
|
||||
ok(eq(mots[3].etiquettes.acces, ['don-libre', 'gratuit-avec-inscription']) && mots[3].chips.length === 2, 'mot → union des formats et des accès de ses ressources')
|
||||
ok(mots[1].resume === 'Créer son activité et en vivre.' && eq(mots[1].etiquettes, { format: [], acces: [] }), 'mot sans ressource (gagner sa vie) → résumé gardé, aucune étiquette')
|
||||
ok(A.toMiniMot({ id: 'x', mot: 'x', resume: '', vide: 'Rien encore. Propose.' }, []).resume === 'Rien encore.', 'mot sans résumé → texte « vide » (première phrase)')
|
||||
ok(eq(A.valeursRessources(RESS, 'acces').map((v) => v.libelle), ['don libre', 'libre', 'financé selon région', 'gratuit avec inscription']) && A.slugEtiquette('financé selon région') === 'finance-selon-region', 'valeurs de dimension : libellé lisible, valeur en slug, ordre de première apparition')
|
||||
const idsM = (q, tri) => ids(V.trierFiches(V.filtrerFiches(mots, etatM(q), cfgMots), tri ?? V.triEffectif(etatM(q), cfgMots), etatM(q)))
|
||||
ok(eq(idsM({}), ['quete-de-sens', 'gagner-sa-vie', 'sante-mentale', 'naviguer-avec-soi']), 'tri ordre : l’ordre du tableau d’entrée, pas A → Z')
|
||||
ok(eq(ids(V.trierFiches([...mots].reverse(), 'ordre', etatM())), ['naviguer-avec-soi', 'sante-mentale', 'gagner-sa-vie', 'quete-de-sens']), 'tri ordre : stable, suit l’entrée quelle qu’elle soit')
|
||||
ok(V.triEffectif(etatM(), cfgMots) === 'ordre', 'triDefaut respecté sans filtre')
|
||||
ok(V.triEffectif(etatM({ q: 'vipassana' }), cfgMots) === 'ordre', 'triDefaut respecté avec une recherche seule (pas de filtre d’étiquette)')
|
||||
ok(V.triEffectif(etatM({ acces: 'don-libre' }), cfgMots) === 'pertinence', 'pertinence prioritaire dès qu’un filtre est actif, même avec triDefaut')
|
||||
ok(V.triEffectif(etatM({ acces: 'don-libre', tri: 'ordre' }), cfgMots) === 'ordre', 'tri explicite (tri=ordre dans l’URL) prioritaire')
|
||||
ok(V.lireQuery({ tri: 'ordre' }, cfgMots).tri === 'ordre', 'tri=ordre lu dans l’URL quand la page l’offre')
|
||||
ok(V.lireQuery({ tri: 'ordre' }, cfg).tri === null && V.lireQuery({ tri: 'ordre' }, sansDefaut).tri === null, 'tri=ordre ignoré quand la page ne l’offre pas (/ et /agences)')
|
||||
ok(V.triEffectif(etatM(), sansDefaut) === 'nom' && V.triEffectif(etat0(), cfg) === 'nom', 'sans triDefaut : comportement inchangé (nom)')
|
||||
ok(V.triEffectif(etatM(), { ...cfgMots, triDefaut: 'recent' }) === 'nom', 'triDefaut absent de tris → ignoré')
|
||||
ok(eq(idsM({ acces: 'don-libre' }), ['naviguer-avec-soi', 'quete-de-sens']), 'filtre Accès « don libre » : pertinence (ex æquo → A → Z), pas l’ordre de la slide', JSON.stringify(idsM({ acces: 'don-libre' })))
|
||||
ok(eq(idsM({ q: 'vipassana' }), ['naviguer-avec-soi']), 'recherche « Vipassana » (titre d’une ressource) → le mot « naviguer avec soi »')
|
||||
ok(eq(idsM({ q: 'VIPASSANA' }), ['naviguer-avec-soi']) && eq(idsM({ q: 'quete' }), ['quete-de-sens']), 'recherche insensible à la casse et aux accents')
|
||||
const cm = V.compterEtiquettes(mots, etatM({ acces: 'don-libre' }), cfgMots)
|
||||
ok(cm.format.film === 1 && cm.format.retraite === 1 && cm.format.formation === 0 && cm.acces['don-libre'] === 2 && cm.acces.libre === 1, 'compteurs : format dans le résultat du filtre Accès, accès dans celui des formats', JSON.stringify(cm))
|
||||
const eMot = V.lireQuery({ tab: 'partir-de-soi', acces: 'don-libre', format: 'film,retraite', fiche: 'quete-de-sens' }, cfgMots)
|
||||
const qMot = V.ecrireQuery(eMot, cfgMots, { tab: 'partir-de-soi', acces: 'don-libre', fiche: 'quete-de-sens' })
|
||||
ok(qMot.tab === 'partir-de-soi' && qMot.fiche === 'quete-de-sens' && qMot.acces === 'don-libre' && qMot.format === 'film,retraite', 'tab= et fiche= survivent à l’écriture des filtres')
|
||||
ok(V.queryAvecFiche({ tab: 'partir-de-soi', vue: 'carte' }, 'sante-mentale').tab === 'partir-de-soi' && V.queryAvecFiche({ tab: 'partir-de-soi', fiche: 'x' }, null).tab === 'partir-de-soi', 'tab= survit à l’ouverture et à la fermeture d’une fiche')
|
||||
|
||||
console.log(`\n${n - fail}/${n}`)
|
||||
process.exit(fail ? 1 : 0)
|
||||
|
||||
+8
-1
@@ -10,13 +10,14 @@
|
||||
import type { MiniFiche } from '../composables/useFicheAdapter'
|
||||
|
||||
export type EtatVue = 'carte' | 'mixte' | 'fiches'
|
||||
export type TriFiches = 'pertinence' | 'nom' | 'recent'
|
||||
export type TriFiches = 'pertinence' | 'nom' | 'recent' | 'ordre'
|
||||
|
||||
export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches']
|
||||
export const LIBELLES_TRI: Record<TriFiches, string> = {
|
||||
pertinence: 'Pertinence',
|
||||
nom: 'A → Z',
|
||||
recent: 'Récentes',
|
||||
ordre: 'Ordre d’origine',
|
||||
}
|
||||
|
||||
export interface ValeurFiltre {
|
||||
@@ -57,6 +58,10 @@ export interface ConfigVueFiches {
|
||||
groupes: GroupeDef[]
|
||||
etats: EtatVue[]
|
||||
sousVues?: SousVuesCarte
|
||||
/** Tri sans filtre actif (défaut : `nom`). Doit figurer dans `tris`. */
|
||||
triDefaut?: TriFiches
|
||||
/** Libellés de tri propres à la page (sinon `LIBELLES_TRI`). */
|
||||
libellesTri?: Partial<Record<TriFiches, string>>
|
||||
}
|
||||
|
||||
export interface EtatFiches {
|
||||
@@ -227,6 +232,7 @@ 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'
|
||||
if (cfg.triDefaut && cfg.tris.includes(cfg.triDefaut)) return cfg.triDefaut
|
||||
return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom'
|
||||
}
|
||||
|
||||
@@ -250,6 +256,7 @@ function idNum(f: MiniFiche): number {
|
||||
|
||||
export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] {
|
||||
const copie = [...fiches]
|
||||
if (tri === 'ordre') return copie // ordre du tableau d'entrée (déjà filtré, jamais réordonné)
|
||||
if (tri === 'nom') return copie.sort(parNom)
|
||||
if (tri === 'pertinence') {
|
||||
const score = new Map(copie.map((f) => [f, pertinence(f, etat)]))
|
||||
|
||||
Reference in New Issue
Block a user