Commit 3d0de287 authored by Kourser's avatar Kourser
Browse files

Bac à sable : les deux champs de réglage enregistraient à chaque frappe

Remplacer 60 par 120 commençait par envoyer « 1 ». L'API refuse — la
borne basse est de 5 — et la réponse en erreur remettait aussitôt
l'ancienne valeur dans le champ. Il devenait impossible de saisir un
nombre dont un préfixe est hors bornes, c'est-à-dire presque tous : 120,
et toute valeur à trois chiffres, étaient hors d'atteinte.

Le défaut touchait les deux champs. Sur le plafond de sessions, il était
plus sournois que bloquant : taper « 5 » de « 50 » enregistrait
réellement 5, une valeur valide mais non voulue, sans que rien ne le
signale.

Ces deux champs étaient les seuls de l'application à enregistrer sur
« onChange ». Partout ailleurs — plafonds d'organisation, réglages SMTP
— un champ numérique garde un état local et attend une action explicite.
Ils rejoignent cette forme : la saisie est libre, l'enregistrement se
fait à la sortie du champ, ou sur Entrée.

Un nombre ne veut rien dire tant qu'il n'est pas fini de taper. C'est la
raison du brouillon local, pas le confort.

Les bornes sont désormais vérifiées AVANT l'aller-retour réseau, et le
message dit d'abord que rien n'a été enregistré, ensuite la règle : le
champ conserve la saisie refusée pour qu'on puisse la corriger sans tout
retaper, et l'écran ne doit pas laisser croire à un enregistrement.

L'accusé de réception disait « Bac à sable ouvert. » quelle que soit
l'action, y compris pour un simple changement de durée. Il nomme
maintenant ce qui a été enregistré.

Vérifié dans le navigateur sur le geste rapporté : sélectionner 60,
taper 1 — le champ garde « 1 », sans erreur ni retour en arrière — puis
120, quitter le champ, et le serveur porte 120. Refus de 3 et de 2000
sans requête, acceptation de la borne haute 1440, champ vidé qui rend
la main au défaut.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 02fd8c70
Loading
Loading
Loading
Loading
+90 −8
Changes for apps/web/src/Instance.tsx: 90 added lines, 8 removed lines.
Original line number Diff line number Diff line
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react';
import type {
  AnnouncementLevel,
  ApiAnnouncementState,
@@ -727,6 +727,19 @@ function SandboxPanel(): JSX.Element {
  const [error, setError] = useState<string | null>(null);
  const [notice, setNotice] = useState<string | null>(null);

  /**
   * Brouillons de saisie des deux champs numeriques.
   *
   * Ils existent parce que ces champs enregistraient A CHAQUE FRAPPE. Remplacer
   * 60 par 120 commencait par envoyer « 1 », que l'API refuse — la borne basse
   * est de 5 — et la reponse en erreur remettait aussitot l'ancienne valeur dans
   * le champ. Il devenait impossible de saisir un nombre dont un prefixe est
   * hors bornes, c'est-a-dire presque tous. Un nombre ne veut rien dire tant
   * qu'il n'est pas fini de taper : on n'enregistre donc qu'a la sortie du champ.
   */
  const [ttlDraft, setTtlDraft] = useState('');
  const [maxDraft, setMaxDraft] = useState('');

  const reload = useCallback(async () => {
    try {
      setSettings(await api.sandboxSettings());
@@ -739,14 +752,26 @@ function SandboxPanel(): JSX.Element {
    void reload();
  }, [reload]);

  async function apply(patch: Parameters<typeof api.updateSandboxSettings>[0]): Promise<void> {
  // Les brouillons suivent le serveur : au chargement, et apres chaque
  // enregistrement reussi. C'est ce qui fait qu'une saisie refusee revient a la
  // valeur reellement en vigueur plutot que de rester affichee comme un fait.
  useEffect(() => {
    if (!settings) return;
    setTtlDraft(String(settings.ttlMinutes ?? settings.defaults.ttlMinutes));
    setMaxDraft(String(settings.maxActive ?? settings.defaults.maxActive));
  }, [settings]);

  async function apply(
    patch: Parameters<typeof api.updateSandboxSettings>[0],
    message: string,
  ): Promise<void> {
    setBusy(true);
    setError(null);
    setNotice(null);
    try {
      const next = await api.updateSandboxSettings(patch);
      setSettings(next);
      setNotice(next.enabled ? t('Bac à sable ouvert.') : t('Bac à sable fermé.'));
      setNotice(message);
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Enregistrement impossible'));
    } finally {
@@ -769,7 +794,7 @@ function SandboxPanel(): JSX.Element {
    ) {
      return;
    }
    await apply({ enabled: open });
    await apply({ enabled: open }, open ? t('Bac à sable ouvert.') : t('Bac à sable fermé.'));
  }

  if (!settings) {
@@ -786,6 +811,38 @@ function SandboxPanel(): JSX.Element {
  const ttl = settings.ttlMinutes ?? settings.defaults.ttlMinutes;
  const max = settings.maxActive ?? settings.defaults.maxActive;

  /**
   * Enregistre un champ numerique quitte par l'exploitant.
   *
   * Un champ vide vaut « valeur par defaut », comme l'annonce le texte sous les
   * champs. Une valeur hors bornes est refusee ICI, sans aller-retour reseau :
   * le message dit la regle entiere, alors que l'erreur de l'API arrivait apres
   * coup sur une saisie encore inachevee.
   */
  async function commit(
    champ: 'ttlMinutes' | 'maxActive',
    brut: string,
    affiche: string,
    borneMin: number,
    borneMax: number,
    horsBornes: string,
    ok: string,
  ): Promise<void> {
    if (brut.trim() === affiche) return;
    const valeur = brut.trim() === '' ? null : Number(brut);
    if (valeur !== null && (!Number.isInteger(valeur) || valeur < borneMin || valeur > borneMax)) {
      setNotice(null);
      setError(horsBornes);
      return;
    }
    await apply({ [champ]: valeur }, ok);
  }

  /** Entree = « j'ai fini » : on quitte le champ, ce qui declenche la sauvegarde. */
  function validerAuClavier(e: KeyboardEvent<HTMLInputElement>): void {
    if (e.key === 'Enter') e.currentTarget.blur();
  }

  return (
    <section className="panel">
      <div className="panel-head">
@@ -827,9 +884,21 @@ function SandboxPanel(): JSX.Element {
            type="number"
            min={5}
            max={1440}
            value={ttl}
            value={ttlDraft}
            disabled={busy}
            onChange={(e) => void apply({ ttlMinutes: Number(e.target.value) || null })}
            onChange={(e) => setTtlDraft(e.target.value)}
            onKeyDown={validerAuClavier}
            onBlur={() =>
              void commit(
                'ttlMinutes',
                ttlDraft,
                String(ttl),
                5,
                1440,
                t('Durée de vie NON enregistrée : un nombre entier de 5 à 1440 minutes (24 h) est attendu.'),
                t('Durée de vie enregistrée.'),
              )
            }
          />
        </label>
        <label className="field">
@@ -838,9 +907,21 @@ function SandboxPanel(): JSX.Element {
            type="number"
            min={1}
            max={50}
            value={max}
            value={maxDraft}
            disabled={busy}
            onChange={(e) => void apply({ maxActive: Number(e.target.value) || null })}
            onChange={(e) => setMaxDraft(e.target.value)}
            onKeyDown={validerAuClavier}
            onBlur={() =>
              void commit(
                'maxActive',
                maxDraft,
                String(max),
                1,
                50,
                t('Plafond NON enregistré : un nombre entier de 1 à 50 est attendu.'),
                t('Plafond de sessions enregistré.'),
              )
            }
          />
        </label>
      </div>
@@ -848,6 +929,7 @@ function SandboxPanel(): JSX.Element {
        {t(
          'Bornes imposées : 5 min à 24 h, et 50 sessions au plus. Elles évitent qu’un réglage vide de son sens la purge ou le plafond.',
        )}{' '}
        {t('La valeur est enregistrée quand vous quittez le champ.')}{' '}
        {settings.ttlMinutes === null || settings.maxActive === null
          ? t('Une valeur non renseignée applique le défaut.')
          : ''}
+8 −0
Changes for apps/web/src/i18n.tsx: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1090,6 +1090,14 @@ const EN: Record<string, string> = {
  'Bornes imposées : 5 min à 24 h, et 50 sessions au plus. Elles évitent qu’un réglage vide de son sens la purge ou le plafond.':
    'Enforced bounds: 5 min to 24 h, and 50 sessions at most. They keep a setting from emptying the purge or the cap of their meaning.',
  'Une valeur non renseignée applique le défaut.': 'An empty value applies the default.',
  'La valeur est enregistrée quand vous quittez le champ.':
    'The value is saved when you leave the field.',
  'Durée de vie NON enregistrée : un nombre entier de 5 à 1440 minutes (24 h) est attendu.':
    'Session lifetime NOT saved: a whole number from 5 to 1440 minutes (24 h) is expected.',
  'Durée de vie enregistrée.': 'Session lifetime saved.',
  'Plafond NON enregistré : un nombre entier de 1 à 50 est attendu.':
    'Cap NOT saved: a whole number from 1 to 50 is expected.',
  'Plafond de sessions enregistré.': 'Session cap saved.',

  // Déroulé sans animateur
  'Exercice autonome': 'Unattended exercise',