import Ajv, { type ValidateFunction } from 'ajv'; import addFormats from 'ajv-formats'; const ajv = new Ajv({ allErrors: true, coerceTypes: true, }); addFormats(ajv); interface FieldElement { name: string; type: string; value: any; } function isFieldElement(x: unknown): x is FieldElement { if (typeof x !== 'object' || !x) { return false; } if (!('name' in x) || !('type' in x) || !('value' in x)) { return false; } return true; } /** * This is the object that ties a group of inputs to a name. * It assumes that every input in the group is of the same type. */ class Field { private _type: string; constructor(private _name: string, private _inputs: FieldElement[]) { if (this._inputs.length === 0) { throw new Error("[Declaform] Error: cannot construct Field with empty inputs array.") } this._type = this._inputs[0]!.type; } get name() { return this._name; } get value(): string | string[] | number | number[] | undefined { switch(this._type) { case 'checkbox': return (this._inputs as HTMLInputElement[]).filter(x => x.checked).map(x => x.value); case 'radio': return (this._inputs as HTMLInputElement[]).find(x => x.checked)?.value; case 'number': return (this._inputs.length > 1) ? this._inputs.map(x => Number(x.value)) : Number(this._inputs[0]!.value); default: case 'date': case 'datatime-local': case 'email': case 'password': case 'select': case 'text': // can assert this._inputs[0]! because we throw Error in constructor if _inputs.length = 0 return (this._inputs.length > 1) ? this._inputs.map(x => x.value) : this._inputs[0]!.value; } } set value(val: string) { switch(this._type) { case 'radio': case 'checkbox': (this._inputs as HTMLInputElement[]) .forEach(input => { if (input.value === val) { input.checked = true; } else { input.checked = false; } }); break; default: case 'date': case 'datatime-local': case 'email': case 'number': case 'password': case 'select': case 'text': this._inputs.forEach(x => x.value = val); break; } } get type() { return this._type; } } export class Declaform extends HTMLElement { static observedAttributes = [ 'customInputs', // Lets user specify additional inputs to be considered "fields" by the form. Example: 'src', // defines api endpoint used to hydrate this object 'action', // defines api endpoint used to submit this object to 'method', // defines http method to use on "action" endpoint 'schema', // url for a remote JSON Schema definition ]; /* The character that gets used to join multiple field values */ static joining_char = ','; private _fields: Field[]; private _form: HTMLFormElement | null; private _validate: ValidateFunction | undefined; ready: Promise; private readyResolve!: () => void; constructor() { super(); this.attachInternals(); this.attachShadow({ mode: 'open' }); this.shadowRoot!.innerHTML = ''; this._fields = []; this._form = null; this._validate = undefined; this.ready = new Promise(resolve => { this.readyResolve = resolve; }) } async connectedCallback() { const formSlot = this.shadowRoot?.querySelector('slot[name="form"]') as HTMLSlotElement; if (!formSlot) { throw new Error('[Declaform] Error: "form" slot is unassigned.') } if (formSlot.assignedElements()[0]?.tagName !== 'FORM') { throw new Error('[Declaform] Error: "form" slot must be assigned to a
.') } this._form = formSlot.assignedElements()[0] as HTMLFormElement; const customInputs = this.getAttribute('customInputs')?.split(',').map(x => x.trim()) ?? []; const selectors = ['input', 'select'].concat(customInputs).map(x => `${x}[name]`).join(','); const inputs = Array.from(this.querySelectorAll(selectors)); // group inputs by name const inputGroups = new Map(); for (const input of inputs) { if (!isFieldElement(input)) { throw new Error(`[Declaform] Error: input does not have all fields: ["name", "type", "value"].`); } const name = input.getAttribute('name'); if (name) { const group = inputGroups.get(name); if (group) { inputGroups.set(name, group.concat(input)) } else { inputGroups.set(name, [input]); } } } for (const [name, inputs] of inputGroups.entries()) { this._fields.push(new Field(name, inputs)) } const src = this.getAttribute('src'); if (src) { this.hydrateFromEndpoint(src); } const schemaUrl = this.getAttribute('schema'); if (schemaUrl) { const res = await fetch(schemaUrl); const schema = await res.json(); this._validate = ajv.compile(schema); } this._form.addEventListener('submit', async (e) => { e.preventDefault(); const isValid = (this.hasAttribute('schema')) ? await this.validate(e) : true; if (isValid) { await this.submit(); } }); this.readyResolve(); } get form() { return this._form; } get fields() { return this._fields; } toObject() { const setProperty = (obj: Record, name: string, value: any) => { const [current, children] = name.split('.'); if (!current) { return; } if (!children) { obj[current] = value; return; } const iter_obj = obj[current] ?? {}; setProperty(iter_obj, children, value); obj[current] = iter_obj; }; let obj = {}; for (const field of this._fields) { setProperty(obj, field.name, field.value) } return obj; } hydrate(obj: Record) { const populateFields = (current: Record, path: string) => { for (const key of Object.keys(current)) { const value = current[key]; const propPath = (path.length) ? `${path}.${key}` : key; if (typeof value !== 'object') { const fields = this.fields.filter(x => x.name === propPath); fields.forEach(x => x.value = value); } else { populateFields(current[key], propPath); } } }; populateFields(obj, ''); } async validate(e?: SubmitEvent) { e?.preventDefault(); if (!this._validate) { console.warn(`[Declaform] Warning: _validate is not defined`) return true; } const obj = this.toObject(); const isValid = this._validate(obj); return this._validate.errors; } private async hydrateFromEndpoint(endpoint: string): Promise { const response = await fetch(endpoint); const obj = await response.json(); this.hydrate(obj); } async submit(): Promise { const method = this.getAttribute('method') ?? 'POST'; const endpoint = new URL(this.getAttribute('action') ?? ''); const obj = this.toObject(); const response = await fetch(endpoint, { method, body: JSON.stringify(obj), headers: { 'Content-Type': 'application/json', } }) } } customElements.define('decla-form', Declaform);