All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 3m6s
CI/CD / build-and-deploy (push) Successful in 29s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
344 lines
16 KiB
JavaScript
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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
}
|
|
|
|
// ── 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 || {},
|
|
});
|
|
},
|
|
};
|
|
}
|