@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 +25 -0
- package/llms.txt +24 -0
- package/package.json +34 -7
- package/src/index.js +129 -2
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.
|
|
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": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|