Files
mabibli/MaBibli.Client/Services/RemonteeRoutes.cs
T
mathieuandClaude Opus 5 0976da90be Sors du chemin de la consultation d'une revue ce qui la modifie
La fiche d'une revue portait tout à la fois : ses numéros, le formulaire
d'ajout, les champs de la revue et sa suppression. Elle prend la forme
déjà tenue par la fiche d'un livre et par celle d'une série — une route
pour regarder, une route pour changer.

« Ajouter un numéro » et « Modifier la revue » remontent à droite du
titre, dans le bloc d'en-tête partagé avec la fiche d'une série : les
deux actions sont visibles sans descendre au bas d'une collection de
trente numéros.

⚠️ Modifier et retirer un numéro n'existent plus QUE dans
/revues/{id}/edition. En consultation, ces deux boutons se déclenchaient
sous le pouce en faisant défiler la liste — même raison que les flèches
d'ordre d'une série, reléguées sur leur propre écran.

⚠️ Ajouter un numéro, lui, RESTE en consultation, et ce n'est pas une
entorse : le geste ne touche pas à la fiche de la revue, il range un
objet de plus, comme noter un prêt depuis la fiche d'un livre. Le
formulaire se replie derrière son bouton, et s'ouvre de lui-même quand
on arrive du scanner avec un numéro lu sur l'add-on EAN-2 : demander un
clic de plus pour saisir ce qu'on tient en main serait un détour.

Un retrait de numéro se confirme désormais, comme la suppression d'un
livre ou d'une revue : il ne se défait pas, et la liste s'égrène sous le
pouce.

⚠️ Les deux routes partagent le paramètre {id} : le routeur ne
redessine rien en passant de l'une à l'autre, d'où l'abonnement à
LocationChanged — piège déjà rencontré sur la fiche livre. Quitter
l'édition referme au passage ce qui n'a de sens que là : formulaire de
numéro ouvert, retrait ou suppression en attente de confirmation.

/revues/{id}/edition entre dans la table de RemonteeRoutes : elle
retombait jusqu'ici sur le repli de branche, donc sur la liste des
revues au lieu de la fiche qu'on venait de quitter.

Deux tests de service viennent avec, sur ce dont l'écran dépend sans le
recalculer : la revue rendue par la modification d'un numéro revient
déjà rangée (corriger une parution déplace le numéro, les numéros sans
date fermant toujours la liste), et un retrait ne touche qu'au numéro
visé. 605 tests au vert.

Pas de vérification en navigateur : le rendu repose sur la relecture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:07:51 +02:00

167 lines
6.6 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, l'ajout et les deux écrans d'édition sont des écrans de la même
// liste. ⚠️ « /souhaits/revues/{id}/edition » ne peut pas se confondre avec
// « /souhaits/{id}/edition » : les modèles n'ont pas le même nombre de segments.
("/souhaits/revues/{id}/edition", "/souhaits"),
("/souhaits/{id}/edition", "/souhaits"),
("/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}/edition", "/revues/{id}"),
("/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);
}
}