// ========================================== // 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`
onChange(f.key, e.target.checked)} />
${error ? html`
${error}
` : ''}
`; case 'file': { const accept = f.validation?.accept || ''; input = html` onChange(f.key, e.target.files?.[0] || null)} />`; break; } default: input = html` onChange(f.key, e.target.value)} />`; } return html`
${input} ${error ? html`
${error}
` : ''}
`; } function FormRenderer({ template, opts }) { const allFields = getAllFields(template); const [data, setData] = useState(() => { const init = { ...(opts.values || {}) }; for (const f of allFields) { if (!(f.key in init) && f.default != null) init[f.key] = f.default; } return init; }); const [errors, setErrors] = useState({}); const [step, setStep] = useState(0); const handleRef = useRef(null); const onChange = (key, value) => { setData(prev => ({ ...prev, [key]: value })); setErrors(prev => { const next = { ...prev }; delete next[key]; return next; }); }; // Expose control handle useEffect(() => { if (handleRef.current) { handleRef.current.getData = () => { const out = {}; for (const f of allFields) { if (f.condition && !evaluateCondition(f.condition, data)) continue; if (f.key in data) out[f.key] = data[f.key]; } return out; }; handleRef.current.setErrors = (errArr) => { const map = {}; for (const e of errArr) map[e.key] = e.message; setErrors(map); }; } }, [data]); const hasFieldsets = template.fieldsets && template.fieldsets.length > 0; if (hasFieldsets) { const fs = template.fieldsets[step]; const isLast = step === template.fieldsets.length - 1; return html`
${fs.label} Step ${step + 1} of ${template.fieldsets.length}
${(fs.fields || []).map(f => { if (f.condition && !evaluateCondition(f.condition, data)) return ''; return html`<${FormFieldInput} field=${f} value=${data[f.key]} onChange=${onChange} error=${errors[f.key]} />`; })}
${step > 0 ? html`` : ''} ${!isLast ? html`` : ''} ${isLast && opts.onSubmit ? html`` : ''}
`; } return html`
${allFields.map(f => { if (f.condition && !evaluateCondition(f.condition, data)) return ''; return html`<${FormFieldInput} field=${f} value=${data[f.key]} onChange=${onChange} error=${errors[f.key]} />`; })} ${opts.onSubmit ? html`
` : ''}
`; } // ── Public API ────────────────────────────── /** * Creates the sw.forms SDK module. * @param {object} restClient — the SDK REST client */ export function createForms(restClient) { return { /** * Render a typed form into a container element. * @param {HTMLElement} container * @param {object} template — TypedFormTemplate JSON * @param {object} [opts] — { onSubmit, values, readOnly } * @returns {{ getData, setErrors, destroy }} */ render(container, template, opts = {}) { const handle = { getData: () => ({}), setErrors: () => {}, destroy: () => {} }; const ConnectedForm = () => { const ref = useRef(handle); return html`<${FormRenderer} template=${template} opts=${{ ...opts }} />`; }; preactRender(html`<${ConnectedForm} />`, container); handle.destroy = () => { preactRender(null, container); }; return handle; }, /** * Client-side validate form data against a template. * @param {object} template — TypedFormTemplate JSON * @param {object} data — field values * @returns {{ valid: boolean, errors: Array<{key: string, message: string}> }} */ validate(template, data) { return validateAll(template, data || {}); }, /** * Server-side validate via REST endpoint. * @param {object} template — TypedFormTemplate JSON * @param {object} data — field values * @returns {Promise<{ valid: boolean, errors: Array<{key: string, message: string}> }>} */ async validateRemote(template, data) { return restClient.post('/api/v1/forms/validate', { template, data: data || {}, }); }, }; }