@mostajs/schema-form 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog — @mostajs/schema-form
2
+
3
+ ## 0.2.0 — 17/08/2026
4
+
5
+ **Version incrémentée pour trois capacités qui existaient déjà en local sans avoir été publiées.**
6
+
7
+ `repeaterScript`, `affichageMontant` et `centimesDepuis` avaient été ajoutés à la 0.1.0 **sans
8
+ incrémenter la version**. Conséquence, constatée par une vérification en dossier vierge : la 0.1.0
9
+ du registre ne les exportait pas, et `@mostajs/carte-restaurant-ui` — qui importe `repeaterScript` —
10
+ **s'installait sans erreur puis cassait à l'import**.
11
+
12
+ C'est le défaut le plus insidieux du versionnement : deux paquets portant le même numéro et un
13
+ contenu différent. Rien ne le signale tant qu'on développe dans le dépôt, où le module local masque
14
+ le publié.
15
+
16
+ **Ajouté — un essai qui fige la surface publique** : toute modification de la liste des exports fait
17
+ échouer la suite avec « INCRÉMENTER la version, mettre à jour llms.txt et README AVANT de publier ».
18
+
19
+ **Rappel des trois capacités concernées**
20
+ - `repeaterScript()` — le bouton « + Ajouter » d'un repeater ne faisait **rien** sans script ; un
21
+ `<template class="sf-tpl">` est désormais rendu même sur liste vide.
22
+ - `affichageMontant` / `centimesDepuis` — saisie d'un montant en unité courante, stockage en
23
+ **entiers** de la plus petite unité. Jamais de flottant sur de l'argent.
24
+
25
+ `CHANGELOG.md` ajouté à `files`.
package/llms.txt CHANGED
@@ -42,6 +42,18 @@ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `nam
42
42
  ## STATUTS (exemple QatraxFlow)
43
43
  `resultat.statut` = Validé / Non validé / En cours / Interrompu (voir `examples/qatraxflow-recette.schema.js`).
44
44
 
45
+ ## LE CHAMP FICHIER (0.3.0)
46
+ `{ path, type: 'fichier', accept?, capture?, multiple? }`
47
+
48
+ - ⚠️ **L'`enctype="multipart/form-data"` est posé AUTOMATIQUEMENT** dès qu'un champ `fichier`
49
+ figure au schéma — répéteurs compris. Sans lui, le navigateur envoie le seul NOM du fichier,
50
+ jamais ses octets : il ne se plaint pas, le serveur reçoit un champ texte, et la photo n'arrive
51
+ jamais. C'est l'oubli le plus fréquent des formulaires de téléversement, et il est SILENCIEUX.
52
+ - ⚠️ **La valeur n'est jamais pré-remplie** : un navigateur refuse qu'une page choisisse un fichier
53
+ (ce serait une exfiltration). Un écran de modification montre la pièce déjà envoyée À CÔTÉ.
54
+ - ⚠️ `capture="environment"` ouvre l'**appareil photo** sur mobile, pas la galerie.
55
+ - ⚠️ Les OCTETS ne passent pas par `readValues` : ils se lisent avec `@mostajs/form-data-extract`.
56
+
45
57
  ## PIÈGES
46
58
  - **Zéro-dép, HTML-string** : `renderForm` rend une chaîne ; côté React, injecter ou porter en composants.
47
59
  - `readValues` : Array traité comme liste [name,value] (pas via `.entries()`), FormData via `.entries()`.
@@ -52,3 +64,15 @@ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `nam
52
64
  ## RÉFÉRENCES
53
65
  docs/00-CARTOGRAPHIE-ET-PROPOSITION.md · examples/demo.html · examples/qatraxflow-recette.schema.js.
54
66
  Compose (à terme) `@mostajs/ui` pour une variante React. Voisin : `@mostajs/settings`, `@mostajs/crud-ui`.
67
+
68
+ ## SURFACE PUBLIQUE (13 membres, figée par un essai — v0.2.0)
69
+ SF_THEME · escapeHtml · getPath · setPath · label · moduleInfo · moduleName ·
70
+ renderForm · renderField · readValues · **repeaterScript** · **affichageMontant** · **centimesDepuis**
71
+
72
+ - repeaterScript() — SANS lui, le bouton « + Ajouter » d'un repeater ne fait RIEN. Le gabarit
73
+ `<template class="sf-tpl">` est rendu même sur liste vide.
74
+ - affichageMontant / centimesDepuis — l'utilisateur saisit en unité courante, le stockage est en
75
+ ENTIERS de la plus petite unité. Jamais de flottant sur de l'argent.
76
+
77
+ ⚠️ Ces trois exports existaient en 0.1.0 LOCALE mais PAS dans la 0.1.0 publiée : la version n'avait
78
+ pas été incrémentée. Un consommateur installait un paquet qui s'importait et cassait à l'usage.
package/package.json CHANGED
@@ -1,14 +1,41 @@
1
1
  {
2
2
  "name": "@mostajs/schema-form",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Interface de SAISIE générique pilotée par schéma (zéro-dép) : sections + champs typés (text/textarea/number/boolean/select/color/date/localized/repeater), chemins imbriqués, multilingue AR/FR/EN. Rendu HTML (renderForm) + relecture (readValues). Réutilisable par toute app @mostajs.",
5
5
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "type": "module",
8
8
  "main": "src/index.js",
9
- "exports": { ".": "./src/index.js" },
10
- "files": ["src", "README.md", "llms.txt", "LICENSE"],
11
- "scripts": { "test": "bash test-scripts/run-tests.sh" },
12
- "devDependencies": { "@mostajs/mjs-unit": "^0.3.1" },
13
- "keywords": ["form", "schema-form", "saisie", "data-entry", "zero-dependency", "localized", "repeater", "mostajs"]
14
- }
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "llms.txt",
16
+ "LICENSE",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "devDependencies": {
20
+ "@mostajs/mjs-unit": "^0.3.1"
21
+ },
22
+ "keywords": [
23
+ "form",
24
+ "schema-form",
25
+ "saisie",
26
+ "data-entry",
27
+ "zero-dependency",
28
+ "localized",
29
+ "repeater",
30
+ "mostajs"
31
+ ],
32
+ "mostajs": {
33
+ "niveau": "N2",
34
+ "nature": "compose",
35
+ "reutilisabilite": "multi-domaine",
36
+ "depend_de": []
37
+ },
38
+ "scripts": {
39
+ "test": "bash test-scripts/run-tests.sh"
40
+ }
41
+ }
package/src/index.js CHANGED
@@ -49,8 +49,32 @@ function renderControl(f, value, name) {
49
49
  return `<input type="number" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}${f.step != null ? ` step="${f.step}"` : ''} style="${inputStyle}">`;
50
50
  case 'boolean':
51
51
  return `<label style="display:inline-flex;gap:.4rem;align-items:center"><input type="checkbox" name="${name}" value="true"${value ? ' checked' : ''}> ${escapeHtml(label(f.help) || 'Oui')}</label>`;
52
+ // ── LE CHAMP FICHIER (0.3.0) ──────────────────────────────────────────
53
+ //
54
+ // ⚠️ `capture` ouvre l'APPAREIL PHOTO sur mobile, au lieu de la galerie. C'est ce qu'on veut
55
+ // pour photographier une facture au comptoir : demander à un exploitant de prendre la photo,
56
+ // de la retrouver dans sa galerie, puis de la choisir, c'est trois occasions d'abandonner.
57
+ //
58
+ // ⚠️ La valeur d'un champ fichier n'est JAMAIS pré-remplie : un navigateur refuse qu'une page
59
+ // décide quel fichier est sélectionné (ce serait une exfiltration). Un formulaire de
60
+ // modification doit donc afficher la pièce DÉJÀ envoyée à côté du champ, pas dedans.
61
+ case 'fichier':
62
+ return `<input type="file" name="${name}"${req}`
63
+ + `${f.accept ? ` accept="${escapeHtml(f.accept)}"` : ''}`
64
+ + `${f.capture ? ` capture="${escapeHtml(f.capture)}"` : ''}`
65
+ + `${f.multiple ? ' multiple' : ''} style="${inputStyle}">`;
52
66
  case 'select':
53
67
  return `<select name="${name}"${req} style="${inputStyle}">${(f.options || []).map((o) => `<option value="${escapeHtml(o.value)}"${String(value) === String(o.value) ? ' selected' : ''}>${escapeHtml(label(o.label ?? o.value))}</option>`).join('')}</select>`;
68
+ case 'montant': {
69
+ // Un texte, PAS un `number` : le contrôle numérique du navigateur rend un flottant, et un
70
+ // flottant n'est pas un montant. La conversion en unités mineures se fait à la relecture,
71
+ // par lecture de la CHAÎNE (voir centimesDepuis).
72
+ const dec = f.decimales ?? 2;
73
+ const aff = value === undefined || value === null || value === '' ? '' : affichageMontant(value, dec);
74
+ return `<div style="display:flex;gap:.4rem;align-items:center">
75
+ <input type="text" inputmode="decimal" name="${name}"${req} value="${escapeHtml(aff)}" placeholder="${escapeHtml(label(f.placeholder) || (dec ? '0,00' : '0'))}" style="${inputStyle}">
76
+ ${f.devise ? `<span style="opacity:.6;font-weight:600">${escapeHtml(f.devise)}</span>` : ''}</div>`;
77
+ }
54
78
  case 'color':
55
79
  return `<input type="color" name="${name}" value="${escapeHtml(value || '#000000')}" style="height:2.2rem;width:3rem;border:1px solid ${SF_THEME.line};border-radius:.4rem">`;
56
80
  case 'date':
@@ -63,7 +87,11 @@ function renderControl(f, value, name) {
63
87
  case 'repeater': {
64
88
  const arr = Array.isArray(value) ? value : [];
65
89
  const items = arr.map((item, i) => renderRepeaterItem(f, item, `${name}[${i}]`, i)).join('');
66
- return `<div class="sf-repeater" data-name="${name}">${items}<button type="button" class="sf-add" data-name="${name}" style="margin-top:.4rem;background:${SF_THEME.blue};color:#fff;border:0;border-radius:.4rem;padding:.35rem .8rem;cursor:pointer">${escapeHtml(label(f.addLabel) || '+ Ajouter')}</button></div>`;
90
+ // Un GABARIT accompagne toujours la liste, même vide : sans lui, « + Ajouter » n'aurait
91
+ // rien à cloner, et une liste qu'on ne peut pas allonger n'est pas une liste — c'est un
92
+ // formulaire figé qui en a l'air.
93
+ const gabarit = `<template class="sf-tpl">${renderRepeaterItem(f, {}, `${name}[__i__]`, 0)}</template>`;
94
+ return `<div class="sf-repeater" data-name="${name}">${items}${gabarit}<button type="button" class="sf-add" style="margin-top:.4rem;background:${SF_THEME.blue};color:#fff;border:0;border-radius:.4rem;padding:.35rem .8rem;cursor:pointer">${escapeHtml(label(f.addLabel) || '+ Ajouter')}</button></div>`;
67
95
  }
68
96
  default: // text
69
97
  return `<input type="text" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}" style="${inputStyle}">`;
@@ -78,6 +106,44 @@ function renderRepeaterItem(f, item, prefix, i) {
78
106
  }
79
107
 
80
108
  /** Rend un champ complet (label + contrôle). `name` = attribut name/chemin. */
109
+ /**
110
+ * Une saisie textuelle → un ENTIER d'unités mineures, **sans jamais passer par un flottant**.
111
+ *
112
+ * `Math.round(4.475 * 100)` rend **447**, pas 448 : la multiplication flottante a déjà perdu la
113
+ * valeur avant l'arrondi. Un centime de moins sur une charge mensuelle, c'est invisible — et c'est
114
+ * exactement pour cela que c'est grave. On lit donc la CHAÎNE, chiffre par chiffre.
115
+ *
116
+ * `''` → `undefined` : une saisie vide est ABSENTE, jamais zéro.
117
+ * Une saisie illisible → `null` : elle ne vaut pas zéro non plus.
118
+ * Au-delà de `decimales`, l'arrondi est commercial (demi au-dessus), calculé sur la chaîne.
119
+ */
120
+ export function centimesDepuis(saisie, decimales = 2) {
121
+ const t = String(saisie ?? '').trim().replace(/[\s\u00a0\u202f]/g, '').replace(',', '.');
122
+ if (t === '') return undefined;
123
+ const m = /^(-?)(\d*)(?:\.(\d*))?$/.exec(t);
124
+ if (!m || (!m[2] && !m[3])) return null;
125
+ const signe = m[1] === '-' ? -1 : 1;
126
+ const entier = m[2] || '0';
127
+ const frac = m[3] || '';
128
+ const gardees = frac.slice(0, decimales).padEnd(decimales, '0');
129
+ const suivant = frac.charCodeAt(decimales) - 48;
130
+ let n = Number(`${entier}${gardees}`);
131
+ if (suivant >= 5 && suivant <= 9) n += 1; // demi au-dessus, en entier
132
+ return signe * n;
133
+ }
134
+
135
+ /** L'inverse, pour réafficher : `4565` → `« 45,65 »`. Affichage seulement ; rien n'en repart. */
136
+ export function affichageMontant(centimes, decimales = 2) {
137
+ if (centimes === undefined || centimes === null) return '';
138
+ const n = Number(centimes);
139
+ if (!Number.isFinite(n)) return '';
140
+ const signe = n < 0 ? '-' : '';
141
+ const brut = String(Math.abs(Math.trunc(n))).padStart(decimales + 1, '0');
142
+ return decimales === 0
143
+ ? `${signe}${brut}`
144
+ : `${signe}${brut.slice(0, -decimales)},${brut.slice(-decimales)}`;
145
+ }
146
+
81
147
  export function renderField(f, value, name = f.path, locale = 'fr') {
82
148
  if (f.type === 'boolean') return `<div class="sf-field" style="margin-bottom:.8rem">${renderControl(f, value, name)}${f.help ? '' : ''}<div style="font-size:.85rem;color:${SF_THEME.ink};margin-top:.2rem;font-weight:600">${escapeHtml(label(f.label, locale))}</div></div>`;
83
149
  return `<div class="sf-field" style="margin-bottom:.9rem">
@@ -98,7 +164,17 @@ export function renderForm(schema, values = {}, opts = {}) {
98
164
  <legend style="padding:0 .5rem;font-weight:700;color:${SF_THEME.blue}">${escapeHtml(label(sec.label, locale) || sec.id)}</legend>${inner}</fieldset>`;
99
165
  }).join('');
100
166
  const submit = opts.submitLabel === null ? '' : `<button type="submit" style="background:${SF_THEME.blue};color:#fff;border:0;border-radius:.45rem;padding:.6rem 1.2rem;font-weight:600;cursor:pointer">${escapeHtml(opts.submitLabel || 'Enregistrer')}</button>`;
101
- return `<form class="sf-form"${opts.id ? ` id="${opts.id}"` : ''}${opts.action ? ` method="post" action="${opts.action}"` : ''}>
167
+ // ── L'ENCTYPE EST POSÉ TOUT SEUL, ET C'EST LE POINT ───────────────────────
168
+ //
169
+ // ⚠️ Un formulaire qui porte un `<input type=file>` SANS `enctype="multipart/form-data"` envoie
170
+ // le seul NOM du fichier, jamais ses octets. Le navigateur ne se plaint pas, le serveur reçoit
171
+ // un champ texte qui ressemble à un fichier, et la photo n'arrive jamais. C'est l'oubli le plus
172
+ // fréquent des formulaires de téléversement, et il est parfaitement silencieux.
173
+ //
174
+ // On ne le laisse donc pas à la charge de l'appelant : dès qu'un champ `fichier` figure au
175
+ // schéma — y compris DANS un répéteur — l'attribut est posé.
176
+ const aUnFichier = JSON.stringify(schema).includes('"fichier"');
177
+ return `<form class="sf-form"${opts.id ? ` id="${opts.id}"` : ''}${opts.action ? ` method="post" action="${opts.action}"` : ''}${aUnFichier ? ' enctype="multipart/form-data"' : ''}>
102
178
  ${schema.title ? `<h2 style="color:${SF_THEME.ink}">${escapeHtml(label(schema.title, locale))}</h2>` : ''}${body}${submit}</form>`;
103
179
  }
104
180
 
@@ -116,9 +192,60 @@ function coerce(obj, fields) {
116
192
  for (const f of fields) {
117
193
  const v = getPath(obj, f.path);
118
194
  if (f.type === 'boolean') setPath(obj, f.path, v === true || v === 'true' || v === 'on');
195
+ else if (f.type === 'montant') setPath(obj, f.path, centimesDepuis(v, f.decimales ?? 2));
119
196
  else if (f.type === 'number' && v !== undefined && v !== '') setPath(obj, f.path, Number(v));
197
+ else if (f.type === 'repeater' && Array.isArray(v)) {
198
+ // La coercition DESCEND dans les items. Sans cela, une quantité saisie dans une liste
199
+ // revenait en CHAÎNE (« 40 ») là où la même quantité, hors liste, revenait en nombre :
200
+ // deux comportements pour un même type, et un `Number.isInteger()` qui échoue au hasard.
201
+ for (const item of v) coerce(item, (f.itemFields || []).map((sf) => ({ ...sf, path: sf.path || sf.name })));
202
+ }
120
203
  }
121
204
  return obj;
122
205
  }
123
206
 
207
+ /**
208
+ * Le comportement des listes extensibles : ajouter, retirer, renuméroter.
209
+ *
210
+ * **Sans lui, le bouton « + Ajouter » ne fait rien** — la liste a l'air extensible et ne l'est pas.
211
+ * Le rendre ici plutôt que dans chaque application évite que chacune le réécrive, mal.
212
+ *
213
+ * Autonome, sans dépendance, à poser une fois dans la page. Le formulaire reste utilisable sans
214
+ * lui : les lignes déjà saisies s'affichent et se soumettent.
215
+ */
216
+ export function repeaterScript() {
217
+ return `<script>
218
+ document.addEventListener('click', (e) => {
219
+ const add = e.target.closest('.sf-add');
220
+ if (add) {
221
+ const zone = add.closest('.sf-repeater');
222
+ const tpl = zone.querySelector('.sf-tpl');
223
+ if (!tpl) return;
224
+ const i = zone.querySelectorAll('.sf-item').length;
225
+ const html = tpl.innerHTML.replace(/__i__/g, String(i));
226
+ const bloc = document.createElement('div');
227
+ bloc.innerHTML = html;
228
+ const item = bloc.firstElementChild;
229
+ item.querySelector('legend') && (item.querySelector('legend').textContent = '#' + (i + 1));
230
+ zone.insertBefore(item, tpl);
231
+ const premier = item.querySelector('input, select, textarea');
232
+ premier && premier.focus();
233
+ return;
234
+ }
235
+ const rm = e.target.closest('.sf-remove');
236
+ if (rm) {
237
+ const zone = rm.closest('.sf-repeater');
238
+ rm.closest('.sf-item').remove();
239
+ // Renuméroter : les index doivent rester contigus, sinon la relecture rend un tableau troué.
240
+ zone.querySelectorAll('.sf-item').forEach((it, k) => {
241
+ it.querySelector('legend') && (it.querySelector('legend').textContent = '#' + (k + 1));
242
+ it.querySelectorAll('[name]').forEach((ch) => {
243
+ ch.name = ch.name.replace(/\\[\\d+\\]/, '[' + k + ']');
244
+ });
245
+ });
246
+ }
247
+ });
248
+ </script>`;
249
+ }
250
+
124
251
  export const moduleName = moduleInfo.name;