// ========================================== // Armature — SDK Forms Module (v0.9.5) // ========================================== // Reusable typed form system. Any package can // declare and render forms, not just workflows. // // Usage: // const handle = sw.forms.render(container, template, opts); // const { valid, errors } = sw.forms.validate(template, data); // const result = await sw.forms.validateRemote(template, data); // ========================================== const { html } = window; const { h, render: preactRender } = preact; const { useState, useEffect, useRef } = hooks; // ── Field type → HTML input mapping ───────── const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; function escAttr(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/ v.max_length) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max_length} characters` }); if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } break; } case 'email': { if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; } if (!EMAIL_RE.test(val)) errs.push({ key: f.key, message: f.label + ' must be a valid email address' }); if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } break; } case 'number': { const n = typeof val === 'number' ? val : parseFloat(val); if (isNaN(n)) { errs.push({ key: f.key, message: f.label + ' must be a number' }); break; } if (v.min != null && n < v.min) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min}` }); if (v.max != null && n > v.max) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max}` }); break; } case 'date': { if (typeof val !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(val)) { errs.push({ key: f.key, message: f.label + ' must be a valid date (YYYY-MM-DD)' }); break; } if (v.min_date && val < v.min_date) errs.push({ key: f.key, message: `${f.label} must be on or after ${v.min_date}` }); if (v.max_date && val > v.max_date) errs.push({ key: f.key, message: `${f.label} must be on or before ${v.max_date}` }); break; } case 'select': { if (f.options && f.options.length > 0) { const valid = f.options.some(o => o.value.toLowerCase() === String(val).toLowerCase()); if (!valid) errs.push({ key: f.key, message: f.label + ' is not a valid option' }); } break; } case 'checkbox': { if (typeof val !== 'boolean') errs.push({ key: f.key, message: f.label + ' must be true or false' }); break; } } return errs; } function validateAll(template, data) { const fields = getAllFields(template); const errs = []; for (const f of fields) { if (f.condition && !evaluateCondition(f.condition, data)) continue; const val = data[f.key]; const present = f.key in data; if (f.required && (!present || val === null || val === undefined || val === '')) { errs.push({ key: f.key, message: (f.label || f.key) + ' is required' }); continue; } if (!present || val === null || val === undefined) continue; const v = f.validation || {}; switch (f.type) { case 'text': case 'textarea': { if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; } if (v.min_length != null && val.length < v.min_length) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min_length} characters` }); if (v.max_length != null && val.length > v.max_length) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max_length} characters` }); if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } break; } case 'email': { if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; } if (!EMAIL_RE.test(val)) errs.push({ key: f.key, message: f.label + ' must be a valid email address' }); if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } break; } case 'number': { const n = typeof val === 'number' ? val : parseFloat(val); if (isNaN(n)) { errs.push({ key: f.key, message: f.label + ' must be a number' }); break; } if (v.min != null && n < v.min) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min}` }); if (v.max != null && n > v.max) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max}` }); break; } case 'date': { if (typeof val !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(val)) { errs.push({ key: f.key, message: f.label + ' must be a valid date (YYYY-MM-DD)' }); break; } if (v.min_date && val < v.min_date) errs.push({ key: f.key, message: `${f.label} must be on or after ${v.min_date}` }); if (v.max_date && val > v.max_date) errs.push({ key: f.key, message: `${f.label} must be on or before ${v.max_date}` }); break; } case 'select': { if (f.options && f.options.length > 0) { const match = f.options.some(o => o.value.toLowerCase() === String(val).toLowerCase()); if (!match) errs.push({ key: f.key, message: f.label + ' is not a valid option' }); } break; } case 'checkbox': { if (typeof val !== 'boolean') errs.push({ key: f.key, message: f.label + ' must be true or false' }); break; } } } return { valid: errs.length === 0, errors: errs }; } function getAllFields(template) { if (template.fieldsets && template.fieldsets.length > 0) { return template.fieldsets.flatMap(fs => fs.fields || []); } return template.fields || []; } // ── Preact Form Components ────────────────── function FormFieldInput({ field, value, onChange, error }) { const f = field; const id = 'sw-ff-' + f.key; let input; switch (f.type) { case 'text': case 'email': case 'date': input = html` onChange(f.key, e.target.value)} />`; break; case 'number': { const attrs = {}; if (f.validation?.min != null) attrs.min = f.validation.min; if (f.validation?.max != null) attrs.max = f.validation.max; if (f.validation?.step != null) attrs.step = f.validation.step; input = html` onChange(f.key, e.target.value !== '' ? parseFloat(e.target.value) : null)} />`; break; } case 'textarea': input = html``; break; case 'select': input = html``; break; case 'checkbox': return html`