This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/sw/sdk/forms.js
Jeffrey Smith f40973c298 Fix sw.forms.render() preact globals
Use window.html + hooks destructuring to match existing SDK
primitive conventions (confirm.js, toast.js pattern).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:57:38 +00:00

344 lines
16 KiB
JavaScript

// ==========================================
// 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
// ── Client-side validation (mirrors Go forms.ValidateFormData) ──
function evaluateCondition(cond, data) {
const val = data[cond.when];
const exists = val !== undefined && val !== null;
const op = cond.op || 'eq';
switch (op) {
case 'exists': return exists;
case 'eq': return exists && String(val) === String(cond.value);
case 'neq': return !exists || String(val) !== String(cond.value);
case 'in': return exists && Array.isArray(cond.value) && cond.value.map(String).includes(String(val));
default: return true;
}
}
function validateField(f, val, present) {
const errs = [];
if (f.condition) return errs; // caller handles condition check
if (f.required && (!present || val === null || val === undefined || val === '')) {
errs.push({ key: f.key, message: (f.label || f.key) + ' is required' });
return errs;
}
if (!present || val === null || val === undefined) return errs;
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 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`<input type=${f.type} id=${id} value=${value || ''} placeholder=${f.placeholder || ''}
onInput=${e => 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`<input type="number" id=${id} value=${value ?? ''} placeholder=${f.placeholder || ''}
...${attrs} onInput=${e => onChange(f.key, e.target.value !== '' ? parseFloat(e.target.value) : null)} />`;
break;
}
case 'textarea':
input = html`<textarea id=${id} rows="4" placeholder=${f.placeholder || ''}
onInput=${e => onChange(f.key, e.target.value)}>${value || ''}</textarea>`;
break;
case 'select':
input = html`<select id=${id} value=${value || ''} onChange=${e => onChange(f.key, e.target.value)}>
<option value="">— Select —</option>
${(f.options || []).map(o => html`<option value=${o.value}>${o.label}</option>`)}
</select>`;
break;
case 'checkbox':
return html`<div class="sw-form-field" data-key=${f.key}>
<div class="sw-form-check">
<input type="checkbox" id=${id} checked=${!!value}
onChange=${e => onChange(f.key, e.target.checked)} />
<label for=${id}>${f.label}${f.required ? html`<span class="required">*</span>` : ''}</label>
</div>
${error ? html`<div class="sw-form-error">${error}</div>` : ''}
</div>`;
case 'file': {
const accept = f.validation?.accept || '';
input = html`<input type="file" id=${id} accept=${accept}
onChange=${e => onChange(f.key, e.target.files?.[0] || null)} />`;
break;
}
default:
input = html`<input type="text" id=${id} value=${value || ''} placeholder=${f.placeholder || ''}
onInput=${e => onChange(f.key, e.target.value)} />`;
}
return html`<div class="sw-form-field" data-key=${f.key}>
<label for=${id}>${f.label}${f.required ? html`<span class="required">*</span>` : ''}</label>
${input}
${error ? html`<div class="sw-form-error">${error}</div>` : ''}
</div>`;
}
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`<div class="sw-form sw-form-progressive">
<div class="sw-form-nav">
<span class="sw-form-step-label">${fs.label}</span>
<span class="sw-form-step-counter">Step ${step + 1} of ${template.fieldsets.length}</span>
</div>
${(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]} />`;
})}
<div class="sw-form-buttons">
${step > 0 ? html`<button type="button" class="sw-btn" onClick=${() => setStep(step - 1)}>Back</button>` : ''}
${!isLast ? html`<button type="button" class="sw-btn sw-btn-primary" onClick=${() => setStep(step + 1)}>Next</button>` : ''}
${isLast && opts.onSubmit ? html`<button type="button" class="sw-btn sw-btn-primary"
onClick=${() => opts.onSubmit(handleRef.current.getData())}>Submit</button>` : ''}
</div>
</div>`;
}
return html`<div class="sw-form">
${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`<div class="sw-form-buttons">
<button type="button" class="sw-btn sw-btn-primary"
onClick=${() => opts.onSubmit(handleRef.current.getData())}>Submit</button>
</div>` : ''}
</div>`;
}
// ── 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 || {},
});
},
};
}