268 lines
8.3 KiB
TypeScript
268 lines
8.3 KiB
TypeScript
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: <object-list>
|
|
'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<void>;
|
|
private readyResolve!: () => void;
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.attachInternals();
|
|
this.attachShadow({ mode: 'open' });
|
|
this.shadowRoot!.innerHTML = '<slot name="form"></slot>';
|
|
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 <form>.')
|
|
}
|
|
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<string, FieldElement[]>();
|
|
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<string, any>, 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<string, any>) {
|
|
const populateFields = (current: Record<string, any>, 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<void> {
|
|
const response = await fetch(endpoint);
|
|
const obj = await response.json();
|
|
this.hydrate(obj);
|
|
}
|
|
|
|
async submit(): Promise<void> {
|
|
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);
|