gestion_table_tournante_libre/src/interface/primitives/commande.js
Mathieu Benoit 876ea0f2ce [ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.

--- FR ---

[ADD] interface : primitives — champ, refus, confirmation, commande

Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00

180 lines
7.3 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La machine d'une commande, et les remèdes d'un refus (§ 2.1, § 8.5,
// § 14.6), purs : Commande.svelte et Refus.svelte les câblent.
//
// Une commande sans confirmation exécute à chaque clic. Une commande à
// confirmer s'arme au premier clic — son libellé devient celui de la
// confirmation — et n'exécute qu'au second ; le composant la désarme au
// terme de DELAI_CONFIRMATION_MS, par l'horloge qu'il reçoit. Aucun chemin
// n'exécute une commande à confirmer en un clic.
//
// Un remède (contrat « Refus et avertissements ») est null, une commande
// exécutable telle quelle, { commande, arguments }, ou un geste,
// { geste, … }. Son libellé est la clé communs.remede.<commande>, ou
// communs.remede.<geste>, suivie de .<variante> quand le geste a plusieurs
// libellés selon ses arguments : la source d'ouvrirSecours, la racine
// d'ecrireAilleurs — documents quand elle est donnée, choisi quand
// l'opérateur la désigne —, la section d'ouvrirSection. Son action passe par
// la séance, par l'état de navigation pour ce qui ne fait que montrer, ou
// par un geste que l'écran fournit : choisirPlace se choisit sur le plan.
import { COMMANDES } from '../../application/commandes.js';
/** Le délai au terme duquel une commande armée se désarme. */
export const DELAI_CONFIRMATION_MS = 10_000;
/** La clé du libellé que prend « Débloquer » une fois armée (§ 8.5). */
export const CONFIRMATION_DEBLOCAGE = 'communs.commande.confirmerDeblocage';
/** Les gestes d'un remède, ceux du contrat de données. */
export const GESTES = Object.freeze([
'modifier',
'debloquer',
'afficherTour',
'ouvrirSection',
'choisirPlace',
'ouvrirSecours',
'ecrireAilleurs',
]);
/** Les sources d'un secours (seance.ouvrirSecours). */
export const SOURCES_SECOURS = Object.freeze(['precedent', 'journal']);
/** Les sections qu'ouvre un remède : celles de l'événement ouvert, dans l'ordre du § 2.1. */
export const SECTIONS_REMEDE = Object.freeze([
'import',
'participants',
'tables',
'plan',
'diagnostic',
'generation',
'propositions',
]);
/**
* Une machine de commande.
*
* @param {{confirmer?: string|null}} [options] la clé du libellé de
* confirmation ; null pour une commande qui exécute au premier clic
* @returns {{readonly armee: boolean, cliquer: () => ({action: 'armer'}|{action: 'executer'}), expirer: () => void}}
* cliquer arme une commande à confirmer désarmée, et exécute sinon, en la
* désarmant ; expirer la désarme.
*/
export function creerMachineCommande({ confirmer = null } = {}) {
let armee = false;
return {
get armee() {
return armee;
},
cliquer() {
if (confirmer !== null && !armee) {
armee = true;
return { action: 'armer' };
}
armee = false;
return { action: 'executer' };
},
expirer() {
armee = false;
},
};
}
// Une valeur d'un remède qui doit appartenir à sa liste : RangeError qui la
// nomme sinon, plutôt qu'une clé de traduction que rien ne porte.
function exigerParmi(valeur, liste, quoi) {
if (!liste.includes(valeur)) throw new RangeError(`remède : ${quoi} inconnu ${JSON.stringify(valeur)}`);
return valeur;
}
/**
* Le libellé d'un remède : sa clé dans la table communs et les détails que
* le texte reçoit — les arguments d'une commande, le tour d'afficherTour.
*
* @param {Object} remede non null
* @returns {{cle: string, details: Object}}
* @throws {RangeError} une commande, un geste, une source ou une section
* hors du registre ou du contrat
*/
export function libelleRemede(remede) {
if (Object.hasOwn(remede, 'commande')) {
if (!COMMANDES.has(remede.commande)) throw new RangeError(`remède : commande inconnue ${JSON.stringify(remede.commande)}`);
return { cle: `communs.remede.${remede.commande}`, details: remede.arguments ?? {} };
}
const geste = exigerParmi(remede.geste, GESTES, 'geste');
if (geste === 'ouvrirSecours') {
return { cle: `communs.remede.ouvrirSecours.${exigerParmi(remede.source, SOURCES_SECOURS, 'source')}`, details: {} };
}
if (geste === 'ecrireAilleurs') {
return { cle: `communs.remede.ecrireAilleurs.${remede.racine ? 'documents' : 'choisi'}`, details: {} };
}
if (geste === 'ouvrirSection') {
return { cle: `communs.remede.ouvrirSection.${exigerParmi(remede.section, SECTIONS_REMEDE, 'section')}`, details: {} };
}
if (geste === 'afficherTour') return { cle: 'communs.remede.afficherTour', details: { tour: remede.tour } };
return { cle: `communs.remede.${geste}`, details: {} };
}
// Ce que fait un geste du remède, par la séance ou par l'état de navigation ;
// null quand ce qui le ferait manque.
function executionDuGeste(remede, seance, ui) {
switch (remede.geste) {
case 'modifier':
return () => seance.passerEnEcriture();
case 'debloquer':
return () => seance.debloquer();
case 'ouvrirSecours':
return () => seance.ouvrirSecours(seance.etat().evenement.base, remede.source);
case 'ecrireAilleurs':
return () => seance.ecrireAilleurs(remede.racine ?? null);
case 'afficherTour':
if (ui === null) return null;
return () => {
ui.montrerProposition(remede.proposition ?? null);
ui.montrerTour(remede.tour);
};
case 'ouvrirSection':
if (ui === null) return null;
return () => ui.allerA(remede.section);
default:
return null;
}
}
/**
* L'action qu'offre un remède : de quoi poser une Commande à côté du refus.
* Un geste que gestes fournit passe par lui, avant tout autre chemin ;
* choisirPlace n'a pas d'autre chemin. Un remède de commande s'exécute par
* seance.executer, tel quel ; modifier, debloquer, ouvrirSecours et
* ecrireAilleurs par la méthode de la séance de même nom — passerEnEcriture
* pour modifier, la base de l'événement ouvert pour ouvrirSecours ;
* afficherTour et ouvrirSection par l'état de navigation, ui.
*
* @param {Object|null} remede
* @param {{seance: Object, ui?: Object|null, gestes?: Object<string, (remede: Object) => unknown>}} dependances
* @returns {null|{nom: string, cle: string, details: Object, confirmer: string|null, executer: () => unknown}}
* null sans remède, ou quand rien ne sait l'exécuter ; nom : la commande
* ou le geste, que porte data-commande ; confirmer : la clé de la
* confirmation, pour debloquer seul, geste ou commande
*/
export function actionRemede(remede, { seance, ui = null, gestes = {} }) {
if (remede === null || remede === undefined) return null;
const { cle, details } = libelleRemede(remede);
const nom = remede.commande ?? remede.geste;
// debloquer se confirme, qu'il vienne en geste ou en commande : aucun
// chemin ne débloque en un clic (§ 8.5).
const confirmer = nom === 'debloquer' ? CONFIRMATION_DEBLOCAGE : null;
let executer;
if (remede.geste !== undefined && Object.hasOwn(gestes, remede.geste)) {
const fourni = gestes[remede.geste];
executer = () => fourni(remede);
} else if (remede.commande !== undefined) {
executer = () => seance.executer(remede.commande, remede.arguments ?? {});
} else {
executer = executionDuGeste(remede, seance, ui ?? null);
}
if (executer === null) return null;
return { nom, cle, details, confirmer, executer };
}