@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 +21 -0
- package/README.md +49 -0
- package/llms.txt +54 -0
- package/package.json +14 -0
- package/src/index.js +124 -0
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|