Feat v0.9.5 typed forms sdk (#79)
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
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>
This commit was merged in pull request #79.
This commit is contained in:
343
src/js/sw/sdk/forms.js
Normal file
343
src/js/sw/sdk/forms.js
Normal file
@@ -0,0 +1,343 @@
|
||||
// ==========================================
|
||||
// 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 || {},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { createRenderers } from './renderers.js';
|
||||
import { createMarkdown } from './markdown.js';
|
||||
import { createUsers } from './users.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createForms } from './forms.js';
|
||||
import { confirm } from '../primitives/confirm.js';
|
||||
import { prompt } from '../primitives/prompt.js';
|
||||
|
||||
@@ -125,6 +126,9 @@ export async function boot() {
|
||||
// Testing — structured test framework for surface runners
|
||||
sw.testing = createTesting(events, restClient);
|
||||
|
||||
// Forms — typed form rendering + validation (v0.9.5)
|
||||
sw.forms = createForms(restClient);
|
||||
|
||||
// Shell helpers — imperative confirm/prompt backed by primitives
|
||||
sw.confirm = confirm;
|
||||
sw.prompt = prompt;
|
||||
@@ -235,7 +239,7 @@ export async function boot() {
|
||||
}
|
||||
|
||||
// Marker for idempotency
|
||||
sw._sdk = '0.9.1';
|
||||
sw._sdk = '0.9.5';
|
||||
|
||||
// 8. Expose globally
|
||||
window.sw = sw;
|
||||
|
||||
Reference in New Issue
Block a user