diff --git a/CLAUDE.md b/CLAUDE.md index 231f398..717fd31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3146,6 +3146,73 @@ n'affirme rien — comparer avec `RangDesEnvies`, qui avait dû reconduire un or **618 tests au vert** (506 au début de la série), aucun avertissement de compilation. +## Les articles à la une se saisissent un par un — champ à séparateur retiré (2026-08-21) + +Défaut remonté en usage sur le champ « À la une (titres séparés par des points-virgules) » : +« impossible de taper des espaces, des `;`, se comporte très bizarrement ». + +### ⚠️ Le séparateur d'un champ relié à chaque frappe est INTAPABLE — et ce n'est pas propre aux unes + +`AjoutNumeroRevue.ArticlesTexte` était une vue « une seule ligne » : son *getter* recomposait +(`ListeSaisie.Joindre`) ce que son *setter* venait de découper (`Decouper`). Reliée en +`@bind:event="oninput"`, elle **réécrivait la saisie à chaque caractère** : + +| Ce qu'on tape | Ce que la propriété rend | Ce qu'on voit | +|---|---|---| +| `Ukraine ;` | `;` ouvre une entrée **vide**, élaguée par `RemoveEmptyEntries` | le `;` **disparaît** | +| `Ukraine ; ` | `TrimEntries` rogne l'espace de fin | l'espace **disparaît** | +| `Ukraine ; L` | `Joindre` repose son propre `« ; »` | le curseur **saute** | + +Autrement dit : **le seul caractère qu'on ne pouvait pas taper dans ce champ était son propre +séparateur**. Le lot O2 avait longuement tranché *quel* séparateur employer (le point-virgule, +parce qu'un titre contient souvent une virgule) sans voir qu'aucun ne pouvait fonctionner par +cette mécanique. + +⚠️ **Deux champs voisins ont exactement la même mécanique et donc le même défaut** : +`EnregistrementLivre.ThemesTexte` et `EnregistrementLivre.Auteur`, tous deux reliés sur +`oninput` à une propriété qui découpe puis recompose. Non corrigés — le retour d'usage ne +portait que sur les unes — mais ce sont les mêmes symptômes, et `Auteur` porte en plus la +reprise des rôles. + +### Ce que l'écran fait maintenant + +Un titre, `Entrée`, et il rejoint une liste où chaque ligne porte **Modifier** et **Supprimer** +(demandé tel quel par l'utilisateur). `Modifier` **reprend le titre dans le champ**, là où on +l'a écrit, et le bouton devient « Enregistrer » avec un « Annuler » à côté. + +- **La liste est la donnée** : plus de propriété texte intermédiaire, plus de découpage, donc + plus rien à ne pas pouvoir taper — ni `;`, ni virgule, ni deux-points. +- **`Entrée` ne remonte pas à `OnEntree`** (qui enregistre le numéro entier) : sinon on ne + pourrait jamais saisir deux titres. +- **Le doublon reste refusé au sens de la normalisation**, avec un message plutôt qu'en silence. +- ⚠️ **Le composant se remet à zéro quand le parent change de `Saisie`** (numéro enregistré, + autre numéro ouvert en modification), en comparant la **référence** : sans cela, le titre resté + dans le champ appartiendrait au numéro d'avant. +- ⚠️ `min-width: 0` sur le titre de la ligne, encore une fois : sans lui, un titre long pousse + « Supprimer » hors d'un écran de 320 px. + +`ArticlesTexte` a disparu, et avec lui le paramètre `virguleAussi` de `ListeSaisie` — il +n'existait que pour ce champ. `ListeSaisie` ne sert plus qu'aux **thèmes**, où la virgule reste +acceptée en repli. + +### Vérifié en exécution (navigateur, 375 puis 320 px) + +Frappe caractère par caractère de `Ukraine, deux ans après ; le retour du train` : **la valeur +reste exactement ce qui a été tapé**, espaces et `;` compris. `Entrée` la range telle quelle, +le champ se vide. Un doublon de casse est refusé avec son message ; Modifier reprend le titre, +Enregistrer le remplace, Supprimer le retire. Numéro 44 créé puis relu par l'API avec ses deux +titres **intacts**, un article ajouté à un numéro existant par l'écran d'édition, et la liste +qui se recharge bien d'un numéro à l'autre. Aucun débordement horizontal à 320 px +(`scrollWidth` = 320). + +⚠️ **Défaut voisin constaté au passage, NON corrigé** : le bouton « Ajouter ce numéro » vit dans +`Revue.razor` alors que le champ « Numéro » vit dans `ChampsNumeroRevue`. Une frappe ne +re-rendant que le composant qui l'a traitée, le bouton **reste désactivé** tant que le parent ne +se redessine pas — seul `Entrée` (qui appelle `OnEntree`, donc le parent) enregistre le numéro. +À traiter. + +617 tests au vert (618 avant : le test du découpage des unes n'a plus d'objet). + ## La 7ᵉ série vérifiée en navigateur — trois défauts, tous invisibles aux tests (2026-08-21) La série Q à Y s'était close sur un aveu : « **aucun** écran n'a été regardé s'afficher ». Le diff --git a/MaBibli.Client/Composants/ChampsNumeroRevue.razor b/MaBibli.Client/Composants/ChampsNumeroRevue.razor index f8a4071..e09f49e 100644 --- a/MaBibli.Client/Composants/ChampsNumeroRevue.razor +++ b/MaBibli.Client/Composants/ChampsNumeroRevue.razor @@ -26,16 +26,77 @@ @* - ⚠️ Séparateur : le POINT-VIRGULE, et pas la virgule des thèmes de livres. Un titre d'article - en contient souvent (« Ukraine, deux ans après »), et la virgule le couperait en deux. + ⚠️ Un titre à la fois, et surtout PAS une ligne de titres séparés par des points-virgules. + Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis recomposait + la liste : la saisie était donc réécrite sous les doigts — un point-virgule tapé disparaissait + (il ouvrait une valeur vide, aussitôt élaguée), et l'espace qui le suit était rogné par le + même élagage. Autrement dit, le séparateur du champ était le seul caractère qu'on ne pouvait + pas y taper. + + Ici la liste est la donnée : on écrit un titre, Entrée l'y range, et chaque titre se modifie + ou se retire ensuite. Une virgule (« Ukraine, deux ans après ») n'a plus rien à couper, + puisque plus rien ne découpe. *@ - +
+ + +
+ + + + + @if (_indexModifie is not null) + { + + } +
+ + @if (_messageArticle is not null) + { +

@_messageArticle

+ } + + @if (Saisie.Articles.Count > 0) + { + + } +
@* ⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne @@ -53,6 +114,106 @@ @code { [Parameter, EditorRequired] public AjoutNumeroRevue Saisie { get; set; } = default!; + /// Identifiant du champ « à la une », pour que son libellé le désigne vraiment. + private readonly string _idChamp = $"une-{Guid.NewGuid():N}"; + + /// Titre en cours de frappe. Il n'est relié à rien d'autre : voir plus haut. + private string? _titreSaisi; + + /// Rang du titre qu'on modifie — null = on en ajoute un nouveau. + private int? _indexModifie; + + private string? _messageArticle; + + /// Saisie servie au rendu précédent, pour repérer que le parent en a changé. + private AjoutNumeroRevue? _saisiePrecedente; + + /// + /// Le parent remplace la saisie entière (numéro enregistré, autre numéro ouvert en + /// modification) : ce qui restait dans le champ appartenait au numéro d'avant. + /// + protected override void OnParametersSet() + { + if (!ReferenceEquals(_saisiePrecedente, Saisie)) + { + _saisiePrecedente = Saisie; + RemettreAZero(); + } + } + + private void RemettreAZero() + { + _titreSaisi = null; + _indexModifie = null; + _messageArticle = null; + } + + /// + /// Entrée range le titre, comme le bouton. + /// + /// + /// ⚠️ Cette touche ne remonte pas à OnEntree : celui-ci enregistre le numéro + /// entier, et l'on serait alors bien en peine de saisir deux titres à la une. + /// + private void SurToucheArticle(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + ValiderArticle(); + } + } + + private void ValiderArticle() + { + var titre = _titreSaisi?.Trim(); + + if (string.IsNullOrEmpty(titre)) + { + return; + } + + // Même règle de doublon que partout ailleurs : au sens de la normalisation, et le + // libellé déjà rangé est celui qui reste. + var cle = NormalisationTexte.Normaliser(titre); + var deja = Saisie.Articles.FindIndex(a => NormalisationTexte.Normaliser(a) == cle); + + if (deja >= 0 && deja != _indexModifie) + { + _messageArticle = "Ce titre est déjà à la une de ce numéro."; + return; + } + + if (_indexModifie is { } index) + { + Saisie.Articles[index] = titre; + } + else + { + Saisie.Articles.Add(titre); + } + + RemettreAZero(); + } + + /// Reprend un titre dans le champ : on le corrige là où on l'a écrit. + private void ModifierArticle(int index) + { + _messageArticle = null; + _indexModifie = index; + _titreSaisi = Saisie.Articles[index]; + } + + private void AnnulerModification() => RemettreAZero(); + + private void SupprimerArticle(int index) + { + Saisie.Articles.RemoveAt(index); + + // Le titre qu'on modifiait vient peut-être de disparaître, et les rangs suivants ont + // glissé : reprendre la modification écrirait sur le mauvais titre. + RemettreAZero(); + } + /// /// Date affichée par le champ, en local. /// diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index 39e2cb3..0062fb3 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -1614,6 +1614,37 @@ body { font-style: italic; } +/* Saisie des unes : un titre par ligne, son texte à gauche et ses deux actions à droite. + ⚠️ min-width: 0 sur le titre est ce qui fait tenir la ligne à 320 px — un titre d'article + est long, et sans lui il pousserait « Supprimer » hors de l'écran (même leçon que la ligne + de création du lot M1, et que l'arbre des séries). */ +.liste-unes { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; +} + +.ligne-une { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + padding: 0.35rem 0; + border-bottom: 1px solid var(--mb-bord); +} + +.ligne-une-titre { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; + font-style: italic; +} + +.ligne-une .actions-tome { + flex: 0 0 auto; + margin-top: 0; +} + /* --- Créer depuis une liste : champ + bouton sur une ligne (lot M1) ---- ⚠️ min-width: 0 sur le champ est ce qui fait tenir la ligne à 320 px : sans lui, un élément de formulaire refuse de rétrécir sous sa largeur intrinsèque diff --git a/MaBibli.Shared/Dtos/EnregistrementLivre.cs b/MaBibli.Shared/Dtos/EnregistrementLivre.cs index 8f88583..667a042 100644 --- a/MaBibli.Shared/Dtos/EnregistrementLivre.cs +++ b/MaBibli.Shared/Dtos/EnregistrementLivre.cs @@ -125,7 +125,7 @@ public record EnregistrementLivre /// n'en contenant jamais. /// public static List DecouperThemes(string? saisie) => - ListeSaisie.Decouper(saisie, virguleAussi: true); + ListeSaisie.Decouper(saisie); } /// Un auteur tel qu'il est saisi : son nom, et son rôle sur ce livre. diff --git a/MaBibli.Shared/Dtos/RevueDto.cs b/MaBibli.Shared/Dtos/RevueDto.cs index 8539b5e..4ee8a77 100644 --- a/MaBibli.Shared/Dtos/RevueDto.cs +++ b/MaBibli.Shared/Dtos/RevueDto.cs @@ -1,6 +1,3 @@ -using System.Text.Json.Serialization; -using MaBibli.Shared.Textes; - namespace MaBibli.Shared.Dtos; /// @@ -71,21 +68,15 @@ public record AjoutNumeroRevue /// public string? CoverUrl { get; set; } - /// Articles à la une, dans l'ordre de la couverture. - public List Articles { get; set; } = []; - /// - /// Vue « une seule ligne » des , pour un champ de saisie simple. + /// Articles à la une, dans l'ordre de la couverture. /// /// - /// ⚠️ Séparateur : le point-virgule seul, contrairement aux thèmes d'un livre. Un - /// titre d'article contient souvent une virgule (« Ukraine, deux ans après »), qui le - /// couperait en deux. + /// ⚠️ La liste est la donnée, et il n'y a plus de vue « une seule ligne ». Elle a + /// existé — un champ texte à points-virgules, relié à chaque frappe sur une propriété qui + /// découpait puis recomposait — et c'était un champ dans lequel on ne pouvait taper ni le + /// séparateur (aussitôt élagué comme entrée vide) ni l'espace qui le suit. Un titre s'ajoute + /// désormais un par un. /// - [JsonIgnore] - public string? ArticlesTexte - { - get => Articles.Count == 0 ? null : ListeSaisie.Joindre(Articles); - set => Articles = ListeSaisie.Decouper(value, virguleAussi: false); - } + public List Articles { get; set; } = []; } diff --git a/MaBibli.Shared/Textes/ListeSaisie.cs b/MaBibli.Shared/Textes/ListeSaisie.cs index 6adfd43..d861018 100644 --- a/MaBibli.Shared/Textes/ListeSaisie.cs +++ b/MaBibli.Shared/Textes/ListeSaisie.cs @@ -1,18 +1,19 @@ namespace MaBibli.Shared.Textes; /// -/// Découpage d'un champ texte qui porte plusieurs valeurs : thèmes d'un livre, articles -/// à la une d'un numéro de revue. +/// Découpage du champ texte qui porte plusieurs thèmes d'un livre. /// /// /// Le séparateur est le point-virgule, comme celui des auteurs -/// (EnregistrementLivre.SeparateurAuteurs) : trois champs voisins du même formulaire ne -/// doivent pas se saisir de trois façons. +/// (EnregistrementLivre.SeparateurAuteurs) : deux champs voisins du même formulaire ne +/// doivent pas se saisir de deux façons. La virgule reste acceptée en repli — elle était le +/// séparateur jusqu'au 2026-08-21, et un thème (« dark fantasy ») n'en contient jamais. /// -/// ⚠️ La virgule n'est acceptée que là où elle ne peut rien couper de travers. Un thème -/// (« dark fantasy ») n'en contient jamais, et l'accepter évite de désapprendre une habitude ; -/// un titre d'article en contient souvent (« Ukraine, deux ans après »), et la virgule y -/// couperait le titre en deux. D'où le paramètre plutôt qu'une règle unique. +/// ⚠️ Ce découpage ne convient pas à tout champ à valeurs multiples. Les articles à la +/// une d'un numéro y sont passés jusqu'au 2026-08-21 et en sont sortis : un titre contient +/// souvent une virgule, et surtout un champ relié à chaque frappe sur une propriété qui découpe +/// puis recompose réécrit la saisie sous les doigts — le séparateur lui-même devient +/// intapable. Ils se saisissent maintenant un par un. /// /// public static class ListeSaisie @@ -28,14 +29,14 @@ public static class ListeSaisie /// normalisation — « Fantasy » et « fantasy » sont la même étiquette, et le premier /// libellé tapé est celui qui reste. /// - public static List Decouper(string? saisie, bool virguleAussi) + public static List Decouper(string? saisie) { if (string.IsNullOrWhiteSpace(saisie)) { return []; } - char[] separateurs = virguleAussi ? [Separateur, ','] : [Separateur]; + char[] separateurs = [Separateur, ',']; var vus = new HashSet(StringComparer.Ordinal); return saisie diff --git a/MaBibli.Tests/ListeSaisieTests.cs b/MaBibli.Tests/ListeSaisieTests.cs index 04b35da..5ddc4c4 100644 --- a/MaBibli.Tests/ListeSaisieTests.cs +++ b/MaBibli.Tests/ListeSaisieTests.cs @@ -4,24 +4,16 @@ using MaBibli.Shared.Textes; namespace MaBibli.Tests; /// -/// Découpage des champs qui portent plusieurs valeurs : thèmes d'un livre, articles à la une -/// d'un numéro (lot O2). +/// Découpage du champ des thèmes d'un livre. /// +/// +/// ⚠️ Les articles à la une d'un numéro passaient ici jusqu'au 2026-08-21. Ils en sont sortis : +/// un champ relié à chaque frappe sur une propriété qui découpe puis recompose réécrit la saisie +/// sous les doigts, et l'on ne pouvait y taper ni le point-virgule ni l'espace qui le suit. Ils +/// se saisissent un par un, et n'ont donc plus de découpage à éprouver. +/// public class ListeSaisieTests { - /// - /// ⚠️ Le cas qui a tranché le séparateur : un titre d'article contient souvent une virgule, - /// et elle le couperait en deux. - /// - [Fact] - public void Un_titre_darticle_garde_ses_virgules() - { - var articles = ListeSaisie.Decouper( - "Ukraine, deux ans après ; Le retour du train de nuit", virguleAussi: false); - - Assert.Equal(["Ukraine, deux ans après", "Le retour du train de nuit"], articles); - } - /// /// Les thèmes acceptent la virgule en repli : elle était le séparateur jusqu'au 2026-08-21, /// et un thème n'en contient jamais. @@ -44,16 +36,16 @@ public class ListeSaisieTests [Fact] public void Les_entrees_vides_disparaissent() { - Assert.Empty(ListeSaisie.Decouper(" ; ; ", virguleAussi: false)); - Assert.Empty(ListeSaisie.Decouper(null, virguleAussi: true)); + Assert.Empty(ListeSaisie.Decouper(" ; ; ")); + Assert.Empty(ListeSaisie.Decouper(null)); } /// La ligne recomposée se redécoupe à l'identique : c'est ce que fait le formulaire. [Fact] public void Joindre_puis_decouper_ne_perd_rien() { - List articles = ["Ukraine, deux ans après", "Le retour du train de nuit"]; + List themes = ["dark fantasy", "space opera"]; - Assert.Equal(articles, ListeSaisie.Decouper(ListeSaisie.Joindre(articles), virguleAussi: false)); + Assert.Equal(themes, ListeSaisie.Decouper(ListeSaisie.Joindre(themes))); } }