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>
This commit is contained in:
mathieu
2026-08-21 14:07:51 +02:00
co-authored by Claude Opus 5
parent 94f0ef15c0
commit 0976da90be
4 changed files with 287 additions and 102 deletions
+225 -101
View File
@@ -1,4 +1,5 @@
@page "/revues/{Id:int}"
@page "/revues/{Id:int}/edition"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@@ -14,26 +15,139 @@
}
else
{
<h1 class="titre-page">@_revue.Titre</h1>
@*
Titre, identité de la revue et actions sur la MÊME ligne (lot W) : disposition
symétrique de celle de la fiche d'une série, et déjà tenue par la bibliographie et la
liste des auteurs. Elle rend les deux actions visibles sans descendre au bas d'une
collection de trente numéros.
*@
<div class="entete-fiche">
<div class="entete-fiche-texte">
<h1 class="titre-page">@_revue.Titre</h1>
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
{
@* K2 : un ISSN se coupe TOUJOURS au même endroit, contrairement aux tranches
d'un ISBN. Aucune table à embarquer, et aucune coupure ne peut être fausse. *@
<span class="code-isbn">ISSN @FormatageIssn.Afficher(_revue.Issn)</span>
}
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
{
<span>@_revue.Editeur</span>
}
</p>
</div>
@if (!ModeEdition)
{
@* K2 : un ISSN se coupe TOUJOURS au même endroit, contrairement aux tranches d'un
ISBN. Aucune table à embarquer, et aucune coupure ne peut être fausse. *@
<span class="code-isbn">ISSN @FormatageIssn.Afficher(_revue.Issn)</span>
<div class="entete-fiche-actions">
@* Ajouter un numéro reste en CONSULTATION, et ce n'est pas une entorse à
« consulter d'abord, modifier ensuite » : 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. Ce sont la modification et le retrait d'un numéro qui vivent, elles,
uniquement dans l'écran d'édition. *@
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="BasculerAjout">
@(_ajout ? "Fermer" : "Ajouter un numéro")
</button>
@if (Reseau.EnLigne)
{
<a class="bouton bouton-compact bouton-principal"
href="@($"revues/{Id}/edition")">Modifier la revue</a>
}
else
{
<button type="button" class="bouton bouton-compact bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Modifier la revue</button>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
{
<span>@_revue.Editeur</span>
}
</p>
</div>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (!ModeEdition && _ajout)
{
<h2 class="titre-section">Ajouter un numéro</h2>
@*
⚠️ Le numéro se saisit à la main, et ce n'est pas un oubli : les deux chiffres de
parution du code-barres ne sont PAS un numéro fiable (CLAUDE.md), et l'add-on EAN-2
qui le porterait vraiment n'a pas pu être vérifié sur un magazine réel.
*@
@if (_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
{
<p class="message-avertissement" role="status">
Le code-barres portait « @NumeroScanne » à côté du code principal, ce qui est
normalement le numéro de parution. <strong>Vérifiez-le sur la couverture</strong>
avant d'enregistrer.
</p>
}
<ChampsNumeroRevue Saisie="_saisie" OnEntree="AjouterAsync" />
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Numero))"
title="@MotifBlocage"
@onclick="AjouterAsync">
Ajouter ce numéro
</button>
</div>
}
@if (ModeEdition)
{
<h2 class="titre-section">La revue</h2>
<label class="champ">
<span class="champ-libelle">Nom</span>
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
</label>
<label class="champ">
<span class="champ-libelle">
ISSN <span class="champ-aide">(identifiant de la revue ; le tiret est posé à l'enregistrement)</span>
</span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@MotifBlocage"
@onclick="EnregistrerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-discret bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="SupprimerAsync">
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils
n'existent que par leur revue.
</p>
}
}
<h2 class="titre-section">Numéros possédés</h2>
@if (_revue.Numeros.Count == 0)
@@ -46,12 +160,13 @@ else
{
@* Du plus récent au plus ancien : devant un kiosque, la question est « ai-je le
dernier ? », pas « par quoi ai-je commencé ». C'est l'inverse d'une série, qui suit
son ordre de lecture. *@
son ordre de lecture. L'ordre est celui rendu par l'API — les numéros sans date
ferment la liste, une date inconnue n'étant pas une date ancienne. *@
<ul class="liste-numeros">
@foreach (var numero in _revue.Numeros)
{
<li class="carte-numero" @key="numero.Id">
@if (_edition == numero.Id)
@if (ModeEdition && _edition == numero.Id)
{
<div class="carte-corps">
<ChampsNumeroRevue Saisie="_saisieEdition" OnEntree="EnregistrerNumeroAsync" />
@@ -65,7 +180,7 @@ else
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours" @onclick="() => _edition = null">
disabled="@_enCours" @onclick="FermerEdition">
Annuler
</button>
</div>
@@ -112,96 +227,37 @@ else
}
</div>
<div class="actions-tome">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Modifier le numéro {numero.Numero}")"
@onclick="() => OuvrirEdition(numero)">
Modifier
</button>
@*
⚠️ Modifier et retirer un numéro n'existent QUE dans l'écran
d'édition : en consultation, ces deux boutons se déclenchaient sous
le pouce en faisant défiler la collection — même raison que les
flèches d'ordre d'une série, reléguées sur leur propre écran.
*@
@if (ModeEdition)
{
<div class="actions-tome">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Modifier le numéro {numero.Numero}")"
@onclick="() => OuvrirEdition(numero)">
Modifier
</button>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Retirer le numéro {numero.Numero}")"
@onclick="() => RetirerAsync(numero)">
Retirer
</button>
</div>
@* Un retrait est destructeur et ne se défait pas : il se
confirme, comme la suppression d'un livre ou d'une revue. *@
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Retirer le numéro {numero.Numero}")"
@onclick="() => RetirerAsync(numero)">
@(_confirmeRetrait == numero.Id ? "Confirmer le retrait" : "Retirer")
</button>
</div>
}
}
</li>
}
</ul>
}
<h2 class="titre-section">Ajouter un numéro</h2>
@*
⚠️ Le numéro se saisit à la main, et ce n'est pas un oubli : les deux chiffres de
parution du code-barres ne sont PAS un numéro fiable (CLAUDE.md), et l'add-on EAN-2 qui
le porterait vraiment n'a pas pu être vérifié sur un magazine réel.
*@
@if (_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
{
<p class="message-avertissement" role="status">
Le code-barres portait « @NumeroScanne » à côté du code principal, ce qui est
normalement le numéro de parution. <strong>Vérifiez-le sur la couverture</strong>
avant d'enregistrer.
</p>
}
<ChampsNumeroRevue Saisie="_saisie" OnEntree="AjouterAsync" />
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Numero))"
title="@MotifBlocage"
@onclick="AjouterAsync">
Ajouter ce numéro
</button>
</div>
<h2 class="titre-section">La revue</h2>
<label class="champ">
<span class="champ-libelle">Nom</span>
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
</label>
<label class="champ">
<span class="champ-libelle">
ISSN <span class="champ-aide">(identifiant de la revue ; le tiret est posé à l'enregistrement)</span>
</span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@MotifBlocage"
@onclick="EnregistrerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-discret bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="SupprimerAsync">
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils n'existent
que par leur revue.
</p>
}
}
@code {
@@ -232,16 +288,54 @@ else
private string? _issn;
private string? _editeur;
/// <summary>Saisie du numéro qu'on ajoute.</summary>
/// <summary>Saisie du numéro qu'on ajoute, et repli du formulaire qui la porte.</summary>
private AjoutNumeroRevue _saisie = new();
private bool _ajout;
/// <summary>Numéro en cours de modification, et sa saisie — <c>null</c> = aucun.</summary>
private int? _edition;
private AjoutNumeroRevue _saisieEdition = new();
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
private int? _confirmeRetrait;
/// <summary>
/// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un
/// numéro se modifie ou se retire.
/// </summary>
/// <remarks>
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : convention du projet depuis
/// la fiche livre. Comme les deux routes partagent le même paramètre <c>Id</c>, le routeur
/// ne déclenche <b>aucun</b> nouveau rendu en passant de l'une à l'autre — d'où l'abonnement
/// à <c>LocationChanged</c>, sans lequel l'URL changerait et l'écran resterait le même.
/// </remarks>
private bool ModeEdition =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
Navigation.LocationChanged += SurNavigation;
}
/// <summary>
/// Quitter l'édition referme ce qui n'a de sens que là : un formulaire de numéro resté
/// ouvert, et une confirmation de retrait en attente.
/// </summary>
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(() =>
{
if (!ModeEdition)
{
_edition = null;
_confirmeRetrait = null;
_confirmeSuppression = false;
}
StateHasChanged();
});
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
@@ -249,7 +343,11 @@ else
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Navigation.LocationChanged -= SurNavigation;
}
protected override Task OnParametersSetAsync() => ChargerAsync();
@@ -266,13 +364,22 @@ else
_editeur = _revue.Editeur;
}
// Un numéro lu au code-barres ouvre le formulaire de lui-même : on vient de scanner,
// demander un clic de plus pour saisir ce qu'on tient en main serait un détour.
if (!_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
{
_numeroPropose = true;
_saisie.Numero = NumeroScanne.Trim();
_ajout = true;
}
}
private void BasculerAjout()
{
_erreur = null;
_ajout = !_ajout;
}
private void Appliquer(RevueDto revue)
{
_revue = revue;
@@ -318,6 +425,7 @@ else
private void OuvrirEdition(NumeroRevueDto numero)
{
_erreur = null;
_confirmeRetrait = null;
_edition = numero.Id;
_saisieEdition = new AjoutNumeroRevue
{
@@ -329,6 +437,8 @@ else
};
}
private void FermerEdition() => _edition = null;
private async Task EnregistrerNumeroAsync()
{
if (_edition is not { } numeroId
@@ -358,9 +468,19 @@ else
private async Task RetirerAsync(NumeroRevueDto numero)
{
// Premier clic : on demande confirmation. Un numéro retiré ne se récupère pas, et la
// liste s'égrène sous le pouce.
if (_confirmeRetrait != numero.Id)
{
_erreur = null;
_confirmeRetrait = numero.Id;
return;
}
_enCours = true;
_erreur = await Api.RetirerNumeroAsync(numero.Id);
_enCours = false;
_confirmeRetrait = null;
// Le numéro retiré était peut-être celui qu'on modifiait : un formulaire ouvert sur une
// ligne disparue n'aurait plus rien à enregistrer.
@@ -392,6 +512,10 @@ else
}
Appliquer(resultat.Valeur!);
// La fiche est à jour : on retombe sur la consultation, comme après l'édition d'un
// livre ou d'une série. Rester en édition inviterait à réenregistrer.
Navigation.NavigateTo($"revues/{Id}");
}
private async Task SupprimerAsync()