Cinq retours d'usage du 2026-08-21 qui ne touchent que l'interface (lots Q, R, S, T et V d'IDEES.md). Aucune migration, aucune entité modifiée. Q — L'ordre des champs du formulaire livre est une décision, pas une mise en page : type de document, thèmes, auteurs, rôles, puis le reste. Le type commande la suite de la saisie — les rôles ne se posent que là — et il fallait descendre tout le formulaire pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend. Rien n'est présélectionné : « non précisé » ne prétend toujours rien, et les rôles gardent leur règle actée (deux auteurs au moins, BD ou non). Des tests verrouillent la préservation des rôles à chaque frappe, que ce réordonnancement ne doit pas entamer. R — Bandeau et menu passent dans un même conteneur collant : sur PC la rangée des six destinations défilait avec la page et devenait inatteignable au bas d'une longue liste. Un seul conteneur, et non deux éléments collants superposés, qui auraient obligé à écrire en dur la hauteur d'un bandeau qui varie avec la pastille hors-ligne et le nom d'utilisateur. Sur téléphone le menu déployé occupe l'écran entier : sous le bandeau, il partageait l'écran avec la liste qu'on quittait. Trois portes de sortie s'ajoutent à la fermeture déjà en place sur LocationChanged — croix, Échap (le calque prend le focus à l'ouverture, comme celui d'agrandissement des couvertures) et clic hors des liens. Le plein écran est explicitement annulé au-delà de 40 rem, sans quoi un menu ouvert au doigt puis une fenêtre agrandie laisseraient un calque sans bascule pour le refermer. Toutes les règles du menu restent en feuille globale. S — ⚠️ Le retour du bandeau devient une remontée hiérarchique d'un cran de route, ce qui RENVERSE la décision actée le 2026-08-20 (« le retour passe par l'historique du navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les allers-retours (filtre, ordre, édition) et l'on cliquait cinq fois sans quitter le même écran. La parenté est une table explicite et testable, pas un découpage naïf d'URL : toutes les routes n'ont pas la forme d'une arborescence, /auteurs/{id}/bibliographie remontant à la liste des auteurs, dont il n'existe pas de fiche. Le garde-fou « ne jamais sortir de l'application » y vit désormais aussi : la fonction rend toujours un chemin interne, là où history.length ne disait pas ce qu'on croyait dans une PWA standalone. js/navigation.js n'a plus d'utilisateur et disparaît. T — Sur la fiche d'une série, « Modifier » et « Changer l'ordre » rejoignent la ligne du titre et de l'avancement, groupés à droite comme la bibliographie et la liste des auteurs le font déjà. Reléguées au bas de l'écran, les actions d'une saga de vingt tomes ne se découvraient qu'après avoir déroulé la liste. « Changer l'ordre » n'apparaît toujours qu'à partir de deux tomes ou deux sous-séries. V — Dans la liste des revues, toute la ligne ouvre la fiche, comme la carte entière le fait au catalogue. Le titre reste un vrai lien — adresse, clavier, clic-milieu — et son clic ne remonte pas jusqu'à la ligne, qui naviguerait une seconde fois ; toute action posée un jour sur cette ligne devra faire de même. ⚠️ Aucune vérification en navigateur : le rendu de ces écrans repose sur la compilation et la relecture. 552 tests au vert (506 avant ce lot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
6.2 KiB
C#
162 lines
6.2 KiB
C#
namespace MaBibli.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Parenté des routes de l'application : à quel écran remonte la flèche de retour du bandeau.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ⚠️ <b>Ceci renverse la décision actée le 2026-08-20</b> (« le retour passe par l'historique du
|
|
/// navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même
|
|
/// écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les
|
|
/// <b>allers-retours</b> (filtre, ordre, édition) : on cliquait cinq fois sans quitter le même
|
|
/// écran. Une remontée d'un cran de route répond à « où suis-je ? », qui est la question posée.
|
|
/// </para>
|
|
/// <para>
|
|
/// La table est <b>explicite</b>, et non un découpage naïf de l'URL : toutes les routes n'ont pas
|
|
/// la forme d'une arborescence (<c>/souhaits/ordre</c> remonte à <c>/souhaits</c>, mais
|
|
/// <c>/auteurs/{id}/bibliographie</c> remonte à <c>/auteurs</c> — la fiche d'un auteur n'existe
|
|
/// pas). Une route qui apparaît dans l'application doit apparaître ici.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>Le retour ne sort jamais de l'application</b> : la fonction rend toujours un chemin
|
|
/// interne, et la racine d'une branche rend la destination de menu correspondante — le catalogue
|
|
/// en dernier ressort. C'est ce qui remplace le test de <c>history.length</c> qui vivait en
|
|
/// JavaScript, et qui ne disait pas ce qu'on croyait dans une PWA <c>standalone</c> : la pile
|
|
/// d'une session y contient aussi ce qui précède l'application.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class RemonteeRoutes
|
|
{
|
|
/// <summary>Le catalogue est la racine : son parent est lui-même, on ne remonte pas plus haut.</summary>
|
|
public const string Racine = "/";
|
|
|
|
/// <summary>
|
|
/// Table de parenté, dans l'ordre de lecture. <c>{id}</c> représente un segment numérique.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La première ligne dont le modèle correspond gagne : les modèles les plus longs sont donc
|
|
/// écrits <b>avant</b> ceux dont ils sont un prolongement.
|
|
/// </remarks>
|
|
private static readonly (string Modele, string Parent)[] Parents =
|
|
[
|
|
// Fiche livre : l'édition retombe sur la consultation, la consultation sur le catalogue.
|
|
("/livres/{id}/edition", "/livres/{id}"),
|
|
("/livres/{id}", Racine),
|
|
|
|
// Séries : les deux écrans de modification retombent sur la consultation de la série.
|
|
("/series/{id}/edition", "/series/{id}"),
|
|
("/series/{id}/ordre", "/series/{id}"),
|
|
("/series/{id}", "/series"),
|
|
("/series", Racine),
|
|
|
|
// Envies : l'ordre et l'ajout sont deux écrans de la même liste.
|
|
("/souhaits/ordre", "/souhaits"),
|
|
("/souhaits/ajout", "/souhaits"),
|
|
("/souhaits", Racine),
|
|
|
|
// Ajout d'un ouvrage : on y entre depuis le catalogue comme depuis les revues, mais le
|
|
// catalogue est la destination de menu de ce qu'on y saisit.
|
|
("/ajout/manuel", Racine),
|
|
("/ajout/isbn", Racine),
|
|
("/ajout", Racine),
|
|
|
|
// Revues. ⚠️ « /revues/ajout » est écrit AVANT « /revues/{id} » : « ajout » n'est pas un
|
|
// identifiant, mais l'ordre de lecture est ce qui le garantit sans ambiguïté.
|
|
("/revues/ajout", "/revues"),
|
|
("/revues/{id}", "/revues"),
|
|
("/revues", Racine),
|
|
|
|
// La bibliographie remonte à la LISTE des auteurs : il n'existe pas de fiche auteur.
|
|
("/auteurs/{id}/bibliographie", "/auteurs"),
|
|
("/auteurs", Racine),
|
|
|
|
("/prets", Racine),
|
|
("/not-found", Racine),
|
|
];
|
|
|
|
/// <summary>
|
|
/// Écran d'où l'on vient hiérarchiquement, à partir d'un chemin d'application.
|
|
/// </summary>
|
|
/// <param name="chemin">
|
|
/// Chemin absolu ou relatif à la base, avec ou sans requête (<c>?auteur=3</c>) ni fragment.
|
|
/// </param>
|
|
/// <returns>Un chemin interne, toujours ; jamais <c>null</c>, jamais une URL absolue.</returns>
|
|
public static string Parent(string? chemin)
|
|
{
|
|
var segments = Segments(chemin);
|
|
|
|
if (segments.Length == 0)
|
|
{
|
|
return Racine;
|
|
}
|
|
|
|
foreach (var (modele, parent) in Parents)
|
|
{
|
|
if (Correspond(Segments(modele), segments))
|
|
{
|
|
return Rendre(parent, segments);
|
|
}
|
|
}
|
|
|
|
// Route inconnue : on retombe sur la destination de menu de sa branche, plutôt que sur
|
|
// un chemin deviné. Une route ajoutée sans sa ligne de table reste ainsi utilisable —
|
|
// mais elle doit être ajoutée : c'est un repli, pas le mécanisme.
|
|
return Parents.FirstOrDefault(p => p.Modele == $"/{segments[0]}").Parent is not null
|
|
? $"/{segments[0]}"
|
|
: Racine;
|
|
}
|
|
|
|
/// <summary>Découpe en segments non vides, requête et fragment retirés.</summary>
|
|
private static string[] Segments(string? chemin)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(chemin))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var utile = chemin.Split('?', '#')[0];
|
|
|
|
return utile.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
}
|
|
|
|
private static bool Correspond(string[] modele, string[] segments)
|
|
{
|
|
if (modele.Length != segments.Length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (var i = 0; i < modele.Length; i++)
|
|
{
|
|
var attendu = modele[i];
|
|
|
|
var ok = attendu == "{id}"
|
|
? int.TryParse(segments[i], out _)
|
|
: string.Equals(attendu, segments[i], StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!ok)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Remplace <c>{id}</c> du parent par l'identifiant lu dans le chemin d'origine.</summary>
|
|
private static string Rendre(string parent, string[] segments)
|
|
{
|
|
if (!parent.Contains("{id}", StringComparison.Ordinal))
|
|
{
|
|
return parent;
|
|
}
|
|
|
|
var identifiant = segments.FirstOrDefault(s => int.TryParse(s, out _));
|
|
|
|
// Sans identifiant lisible, on ne fabrique pas une route bancale : la racine est sûre.
|
|
return identifiant is null
|
|
? Racine
|
|
: parent.Replace("{id}", identifiant, StringComparison.Ordinal);
|
|
}
|
|
}
|