@mostajs/schema-form 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ @mostajs/mjs-unit
2
+ Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
3
+
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ The complete text of the GNU Affero General Public License version 3 is
20
+ available at the URL above and must accompany any distribution of this
21
+ software.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @mostajs/schema-form
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · Licence : AGPL-3.0-or-later · **Niveau** : N2
4
+ Proposition : `docs/00-CARTOGRAPHIE-ET-PROPOSITION.md` (cas C, règle d'or §0)
5
+
6
+ Interface de **saisie générique pilotée par schéma**, **zéro-dépendance** (HTML-string) —
7
+ réutilisable par **toute app** `@mostajs`. Généralise le `SchemaForm` de TicketFlow :
8
+ sections, champs typés, **chemins imbriqués**, **multilingue AR/FR/EN**, **repeater**.
9
+
10
+ ## Installation
11
+ ```bash
12
+ npm i @mostajs/schema-form
13
+ ```
14
+
15
+ ## Exemple
16
+ ```js
17
+ import { renderForm, readValues } from '@mostajs/schema-form';
18
+
19
+ const schema = {
20
+ title: 'Fiche de recette',
21
+ sections: [
22
+ { id: 'condition', label: 'Condition', fields: [
23
+ { path: 'condition.env', type: 'text', label: 'Environnement', required: true },
24
+ ] },
25
+ { id: 'resultat', label: 'Logs & Résultats', fields: [
26
+ { path: 'resultat.statut', type: 'select', label: 'Statut', options: [
27
+ { value: 'valide', label: 'Validé' }, { value: 'non_valide', label: 'Non validé' },
28
+ { value: 'en_cours', label: 'En cours' }, { value: 'interrompu', label: 'Interrompu' } ] },
29
+ { path: 'resultat.logs', type: 'textarea', label: 'Logs JSON-lines' },
30
+ ] },
31
+ ],
32
+ };
33
+
34
+ el.innerHTML = renderForm(schema, { condition: { env: 'windows-11' } }, { id: 'f' });
35
+ form.onsubmit = (e) => { e.preventDefault(); const values = readValues(new FormData(form), schema); save(values); };
36
+ ```
37
+
38
+ ## Types de champs
39
+ `text` · `textarea` · `number` · `boolean` · `select` · `color` · `date` ·
40
+ `localized` (un input par locale, RTL pour `ar`) · `repeater` (liste d'objets, `itemFields`).
41
+
42
+ ## Positionnement
43
+ - `@mostajs/settings` → réglages **clé-valeur plat** (reste le bon choix pour ça).
44
+ - `@mostajs/crud-ui` → formulaire+table **d'entité CRUD**.
45
+ - **`@mostajs/schema-form`** → saisie **riche générique** (nested, repeater, localized, sections).
46
+
47
+ ## Démo & schéma d'exemple
48
+ `examples/demo.html` (schéma QatraxFlow : Condition→Exécution→Comportement→Logs/Résultats) ;
49
+ `examples/qatraxflow-recette.schema.js`. Tests : `npm test` → `test-scripts/.out/report.html`.
package/llms.txt ADDED
@@ -0,0 +1,54 @@
1
+ # @mostajs/schema-form — fiche LLM
2
+ > Interface de SAISIE générique pilotée par schéma (zéro-dép HTML), réutilisable par toute app.
3
+
4
+ - Version : 0.1.0 · Licence : AGPL-3.0-or-later · Auteur : Dr Hamid MADANI <drmdh@msn.com>
5
+ - Chemin : mostajs/mosta-ui-html-stack/mosta-schema-form · **Niveau** : N2 (composé/UI)
6
+ - Zéro dépendance runtime. Généralise le SchemaForm de TicketFlow (cas C, règle d'or §0).
7
+
8
+ ## RÔLE
9
+ Rend un formulaire de saisie à partir d'un SCHÉMA de champs (sections + champs typés,
10
+ chemins imbriqués, multilingue AR/FR/EN, repeater/listes) et relit les valeurs en objet
11
+ imbriqué. Aucun couplage métier : tout schéma. 1er consommateur : MostaQatraxFlow (fiche
12
+ de recette Condition→Exécution→Comportement→Logs/Résultats). Complète `@mostajs/settings`
13
+ (clé-valeur plat) et `@mostajs/crud-ui` (CRUD) sans les remplacer.
14
+
15
+ ## EXPORTS
16
+ - `renderForm(schema, values?, opts?) : string` — HTML `<form>` (opts: id, action, locale, submitLabel|null)
17
+ - `renderField(field, value, name?, locale?) : string` — un champ
18
+ - `readValues(formData|entries|obj, schema?) : object` — reconstruit l'objet (coerce boolean/number)
19
+ - `getPath(obj,path)` / `setPath(obj,path,val)` — chemins `a.b[0].c`
20
+ - `label(l, locale)` — libellé string ou {ar,fr,en} · `escapeHtml` · `SF_THEME` · `moduleInfo`
21
+
22
+ ## SCHÉMA
23
+ ```
24
+ { title?, sections?: [{ id, label, fields:[Field] }], fields?:[Field] }
25
+ Field = { path, type, label, required?, placeholder?, help?, options?, min?, max?, step?, rows?,
26
+ locales?, itemFields?, itemDefaults?, addLabel? }
27
+ label = string | { ar, fr, en }
28
+ ```
29
+ Types : `text` (défaut) · `textarea` · `number` · `boolean` (checkbox) · `select` (options
30
+ `{value,label}`) · `color` · `date` · `localized` (un input/locale, RTL pour ar) · `repeater`
31
+ (liste d'objets, `itemFields`, add/remove).
32
+
33
+ ## PATTERN
34
+ ```js
35
+ import { renderForm, readValues } from '@mostajs/schema-form'
36
+ el.innerHTML = renderForm(schema, values, { id: 'f' })
37
+ form.onsubmit = (e) => { e.preventDefault(); const v = readValues(new FormData(form), schema); save(v) }
38
+ ```
39
+ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `name`) / remove
40
+ (retirer le `.sf-item`) — cf. `examples/demo.html`.
41
+
42
+ ## STATUTS (exemple QatraxFlow)
43
+ `resultat.statut` = Validé / Non validé / En cours / Interrompu (voir `examples/qatraxflow-recette.schema.js`).
44
+
45
+ ## PIÈGES
46
+ - **Zéro-dép, HTML-string** : `renderForm` rend une chaîne ; côté React, injecter ou porter en composants.
47
+ - `readValues` : Array traité comme liste [name,value] (pas via `.entries()`), FormData via `.entries()`.
48
+ - `boolean` : checkbox non cochée = absente du FormData → `coerce` la met à `false`.
49
+ - Repeater : le rendu statique liste les items ; l'ajout/retrait dynamique = client (démo).
50
+ - `localized` : valeur = objet `{ar,fr,en}` ; names `path.ar`/`path.fr`/`path.en`.
51
+
52
+ ## RÉFÉRENCES
53
+ docs/00-CARTOGRAPHIE-ET-PROPOSITION.md · examples/demo.html · examples/qatraxflow-recette.schema.js.
54
+ Compose (à terme) `@mostajs/ui` pour une variante React. Voisin : `@mostajs/settings`, `@mostajs/crud-ui`.
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@mostajs/schema-form",
3
+ "version": "0.1.0",
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
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
+ "license": "AGPL-3.0-or-later",
7
+ "type": "module",
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
+ }
package/src/index.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @mostajs/schema-form — interface de SAISIE générique pilotée par schéma (ZÉRO-DÉP).
3
+ * @author Dr Hamid MADANI <drmdh@msn.com>
4
+ *
5
+ * Généralise le pattern SchemaForm de TicketFlow en module réutilisable par TOUTE app :
6
+ * un schéma de champs (sections + champs typés, chemins imbriqués, multilingue, repeater)
7
+ * → HTML de formulaire (`renderForm`) + relecture des valeurs (`readValues`). Aucune
8
+ * dépendance, aucun framework (embarquable partout : statique, Express, Next, React).
9
+ * 1er consommateur : MostaQatraxFlow (saisie de recette Condition→Exécution→Comportement→Logs).
10
+ */
11
+
12
+ export const moduleInfo = { name: '@mostajs/schema-form', version: '0.1.0' };
13
+ export const SF_THEME = { blue: '#1E88E5', ink: '#1a2b45', line: '#e3e9f2', bg: '#f7f9fc' };
14
+ const LOCALES = ['ar', 'fr', 'en'];
15
+
16
+ export function escapeHtml(s) {
17
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
18
+ }
19
+ /** Libellé : string ou objet multilingue {ar,fr,en}. */
20
+ export function label(l, locale = 'fr') { return typeof l === 'object' && l ? (l[locale] ?? l.fr ?? Object.values(l)[0] ?? '') : (l ?? ''); }
21
+
22
+ /* ── chemins imbriqués (a.b, a.b[0].c) ─────────────────────────────────────── */
23
+ export function getPath(obj, path) {
24
+ if (!path) return undefined;
25
+ return path.replace(/\[(\d+)\]/g, '.$1').split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
26
+ }
27
+ export function setPath(obj, path, value) {
28
+ const keys = path.replace(/\[(\d+)\]/g, '.$1').split('.');
29
+ let o = obj;
30
+ for (let i = 0; i < keys.length - 1; i++) {
31
+ const k = keys[i], nextIsIndex = /^\d+$/.test(keys[i + 1]);
32
+ if (o[k] == null || typeof o[k] !== 'object') o[k] = nextIsIndex ? [] : {};
33
+ o = o[k];
34
+ }
35
+ o[keys[keys.length - 1]] = value;
36
+ return obj;
37
+ }
38
+
39
+ /* ── rendu d'un champ ──────────────────────────────────────────────────────── */
40
+ const inputStyle = `width:100%;padding:.5rem;border:1px solid ${SF_THEME.line};border-radius:.4rem;font:inherit`;
41
+
42
+ function renderControl(f, value, name) {
43
+ const req = f.required ? ' required' : '';
44
+ const ph = f.placeholder ? ` placeholder="${escapeHtml(label(f.placeholder))}"` : '';
45
+ switch (f.type) {
46
+ case 'textarea':
47
+ return `<textarea name="${name}"${req}${ph} rows="${f.rows || 4}" style="${inputStyle};font-family:ui-monospace,monospace">${escapeHtml(value ?? '')}</textarea>`;
48
+ case 'number':
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
+ case 'boolean':
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
+ case 'select':
53
+ 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>`;
54
+ case 'color':
55
+ 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
+ case 'date':
57
+ return `<input type="date" name="${name}"${req} value="${escapeHtml(value ?? '')}" style="${inputStyle}">`;
58
+ case 'localized': {
59
+ const locs = f.locales || LOCALES;
60
+ return `<div style="display:flex;flex-direction:column;gap:.3rem">${locs.map((lc) =>
61
+ `<div style="display:flex;gap:.4rem;align-items:center"><span style="width:2rem;font-size:.72rem;opacity:.6;text-transform:uppercase">${lc}</span><input name="${name}.${lc}"${lc === 'ar' ? ' dir="rtl"' : ''} value="${escapeHtml((value && value[lc]) ?? '')}" style="${inputStyle}"></div>`).join('')}</div>`;
62
+ }
63
+ case 'repeater': {
64
+ const arr = Array.isArray(value) ? value : [];
65
+ 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>`;
67
+ }
68
+ default: // text
69
+ return `<input type="text" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}" style="${inputStyle}">`;
70
+ }
71
+ }
72
+ function renderRepeaterItem(f, item, prefix, i) {
73
+ return `<fieldset class="sf-item" style="border:1px solid ${SF_THEME.line};border-radius:.5rem;padding:.6rem;margin-bottom:.5rem">
74
+ <legend style="font-size:.75rem;opacity:.6">#${i + 1}</legend>
75
+ ${(f.itemFields || []).map((sub) => renderField({ ...sub, path: sub.path || sub.name }, getPath(item, sub.path || sub.name), `${prefix}.${sub.path || sub.name}`)).join('')}
76
+ <button type="button" class="sf-remove" style="background:#C62828;color:#fff;border:0;border-radius:.4rem;padding:.2rem .6rem;cursor:pointer;font-size:.75rem">Retirer</button>
77
+ </fieldset>`;
78
+ }
79
+
80
+ /** Rend un champ complet (label + contrôle). `name` = attribut name/chemin. */
81
+ export function renderField(f, value, name = f.path, locale = 'fr') {
82
+ 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
+ return `<div class="sf-field" style="margin-bottom:.9rem">
84
+ <label style="display:block;font-size:.85rem;font-weight:600;color:${SF_THEME.ink};margin-bottom:.25rem">${escapeHtml(label(f.label, locale))}${f.required ? ' <span style="color:#C62828">*</span>' : ''}</label>
85
+ ${renderControl(f, value, name)}
86
+ ${f.help && f.type !== 'boolean' ? `<div style="font-size:.75rem;opacity:.6;margin-top:.2rem">${escapeHtml(label(f.help, locale))}</div>` : ''}
87
+ </div>`;
88
+ }
89
+
90
+ /** Rend un formulaire complet depuis un schéma { title?, sections?[], fields?[] }. */
91
+ export function renderForm(schema, values = {}, opts = {}) {
92
+ const locale = opts.locale || 'fr';
93
+ const sections = schema.sections || [{ fields: schema.fields || [] }];
94
+ const body = sections.map((sec) => {
95
+ const inner = (sec.fields || []).map((f) => renderField(f, getPath(values, f.path), f.path, locale)).join('');
96
+ if (!sec.label && !sec.id) return inner;
97
+ return `<fieldset style="border:1px solid ${SF_THEME.line};border-radius:.6rem;padding:1rem;margin-bottom:1rem">
98
+ <legend style="padding:0 .5rem;font-weight:700;color:${SF_THEME.blue}">${escapeHtml(label(sec.label, locale) || sec.id)}</legend>${inner}</fieldset>`;
99
+ }).join('');
100
+ 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}"` : ''}>
102
+ ${schema.title ? `<h2 style="color:${SF_THEME.ink}">${escapeHtml(label(schema.title, locale))}</h2>` : ''}${body}${submit}</form>`;
103
+ }
104
+
105
+ /* ── relecture des valeurs (FormData → objet imbriqué) ─────────────────────── */
106
+ /** Reconstruit l'objet de valeurs depuis un FormData (ou un iterable [name,value]). */
107
+ export function readValues(formData, schema) {
108
+ const out = {};
109
+ const entries = Array.isArray(formData) ? formData
110
+ : (typeof formData?.entries === 'function' ? [...formData.entries()] : Object.entries(formData || {}));
111
+ for (const [name, raw] of entries) setPath(out, name, raw);
112
+ if (schema) coerce(out, schema.sections ? schema.sections.flatMap((s) => s.fields || []) : (schema.fields || []));
113
+ return out;
114
+ }
115
+ function coerce(obj, fields) {
116
+ for (const f of fields) {
117
+ const v = getPath(obj, f.path);
118
+ if (f.type === 'boolean') setPath(obj, f.path, v === true || v === 'true' || v === 'on');
119
+ else if (f.type === 'number' && v !== undefined && v !== '') setPath(obj, f.path, Number(v));
120
+ }
121
+ return obj;
122
+ }
123
+
124
+ export const moduleName = moduleInfo.name;