UNPKG

@ekisa-cdk/forms

Version:

🛠️ Easily build & integrate dynamic forms

893 lines (874 loc) 29.2 kB
class AbstractControl { get errors() { const errors = []; for (const validator of this.validators) { const result = validator(this); if (result) { errors.push(result); } } return errors.length === 0 ? null : errors; } constructor(value, options = { key: "" }) { this.value = value; this.key = options.key; this.label = options.label; this.type = options.type; this.validators = options.validators || []; } getElement() { return document.querySelector(`#${this.key}`); } getParentElement() { return document.querySelector(`#${this.key}`)?.closest('[data-unit-type="Wrapper"]'); } getValidationsElement() { return this.getParentElement()?.querySelector('[data-unit-type="ValidationsWrapper"]'); } getValue() { const element = this.getElement(); if (!element) return null; return element?.value.trim() || null; } setValue(value) { const element = this.getElement(); if (!element) return; element.value = value; } } class CheckBox extends AbstractControl { constructor(value, options = { key: "", label: "" }) { super(value, options); this.type = "CheckBox"; this.value = value; this.key = options.key; this.label = options.label; this.validators = options.validators || []; } getValue() { const node = this.getElement(); return node?.checked || false; } setValue(value) { const node = this.getElement(); if (!node) return; node.checked = value; } } class DatePicker extends AbstractControl { constructor(value, options = { key: "" }) { super(value, options); this.type = "DatePicker"; this.value = value; this.key = options.key; this.label = options.label; this.validators = options.validators || []; } getValue() { const node = this.getElement(); return node?.valueAsDate || null; } setValue(value) { const node = this.getElement(); if (!node) return; node.valueAsDate = value; } } class FieldSet { constructor(options = {}) { this.type = "FieldSet"; this.children = []; this.legend = options.legend; this.cols = options.cols || 1; this.children = options.children || []; } } class NumberBox extends AbstractControl { constructor(value, options = { key: "" }) { super(value, options); this.type = "NumberBox"; this.value = value; this.key = options.key; this.label = options.label; this.validators = options.validators || []; this.min = options.min; this.max = options.max; } getValue() { const node = this.getElement(); return node?.valueAsNumber || null; } setValue(value) { const node = this.getElement(); if (!node) return; node.valueAsNumber = value; } } class RadioGroup extends AbstractControl { constructor(value, options = { key: "", items: [] }) { super(value, options); this.type = "RadioGroup"; this.items = []; this.value = value; this.key = options.key; this.text = options.text; this.items = options.items; this.validators = options.validators || []; } getValue() { const node = Array.from(document.querySelectorAll(`[name=${this.key}]`))?.find((n) => n.checked); return node?.value || null; } setValue(value) { const node = Array.from(document.querySelectorAll(`[name=${this.key}]`))?.find((n) => n.value === value); if (!node) return; node.checked = true; } reset() { Array.from(document.querySelectorAll(`[name=${this.key}]`))?.forEach((n) => n.checked = false); } } class SelectBox extends AbstractControl { constructor(value, options = { key: "" }) { super(value, options); this.type = "SelectBox"; this.options = []; this.value = value; this.key = options.key; this.label = options.label; this.options = options.options || []; this.validators = options.validators || []; } } class TextArea extends AbstractControl { constructor(value, options = { key: "" }) { super(value, options); this.type = "TextArea"; this.value = value; this.key = options.key; this.label = options.label; this.validators = options.validators || []; this.placeholder = options.placeholder; this.cols = options.cols; this.rows = options.rows; } } class TextBox extends AbstractControl { constructor(value, options = { key: "" }) { super(value, options); this.type = "TextBox"; this.value = value; this.key = options.key; this.label = options.label; this.placeholder = options.placeholder; this.validators = options.validators || []; } } class TimePicker extends AbstractControl { constructor(value, options = { key: "" }) { super(value, options); this.type = "TimePicker"; this.value = value; this.key = options.key; this.label = options.label; this.validators = options.validators || []; } getValue() { const node = this.getElement(); return node?.valueAsDate || null; } setValue(value) { const node = this.getElement(); if (!node) return; node.valueAsDate = value; } } class AbstractForm { } const buildTextBox = (config) => { const input = document.createElement("input"); input.dataset.unitType = "TextBox"; input.type = "text"; input.id = config.key; input.name = config.key; input.placeholder = config.placeholder ?? ""; input.value = config.value ?? ""; return input; }; const buildTextArea = (config) => { const textarea = document.createElement("textarea"); textarea.dataset.unitType = "TextArea"; textarea.id = config.key; textarea.name = config.key; textarea.placeholder = config.placeholder ?? ""; textarea.value = config.value ?? ""; if (config.cols) { textarea.cols = config.cols; } if (config.rows) { textarea.rows = config.rows; } return textarea; }; const buildNumberBox = (config) => { const input = document.createElement("input"); input.dataset.unitType = "NumberBox"; input.id = config.key; input.name = config.key; input.type = "number"; if (config.value !== void 0 && config.value !== null) { input.valueAsNumber = config.value; } if (config.min !== void 0) { input.min = config.min.toString(); } if (config.max !== void 0) { input.max = config.max.toString(); } return input; }; const buildSelectBox = (config) => { const select = document.createElement("select"); select.dataset.unitType = "SelectBox"; select.id = config.key; select.name = config.key; for (const opt of config.options) { const option = document.createElement("option"); option.dataset.unitType = "SelectBoxOption"; option.value = opt.value; option.text = opt.text; select.append(option); } if (config.value) { select.value = config.value; } return select; }; const buildCheckBox = (config) => { const input = document.createElement("input"); input.dataset.unitType = "CheckBoxItem"; input.id = config.key; input.name = config.key; input.type = "checkbox"; input.checked = config.value ?? false; const itemWrapper = document.createElement("div"); itemWrapper.dataset.unitType = "CheckBoxItemWrapper"; itemWrapper.append(input); itemWrapper.append(buildLabel(config.label, config.key)); return itemWrapper; }; const buildRadioGroup = (config) => { const wrapper = document.createElement("div"); wrapper.dataset.unitType = "RadioGroup"; wrapper.id = config.key; if (config.text) { const p = document.createElement("p"); p.dataset.unitType = "RadioGroupText"; p.textContent = config.text; wrapper.append(p); } for (let i = 0; i < config.items.length; i++) { const item = config.items[i]; const id = config.key + i; const input = document.createElement("input"); input.dataset.unitType = "RadioGroupItem"; input.type = "radio"; input.id = id; input.name = config.key; input.value = item.value; const itemWrapper = document.createElement("div"); itemWrapper.dataset.unitType = "RadioGroupItemWrapper"; itemWrapper.append(input); itemWrapper.append(buildLabel(item.label, id)); wrapper.append(itemWrapper); } if (config.value) { const node = Array.from(wrapper.querySelectorAll(`[name=${config.key}]`))?.find((node2) => node2.value === config.value); if (node) { node.checked = true; } } return wrapper; }; const buildDatePicker = (config) => { const input = document.createElement("input"); input.dataset.unitType = "DatePicker"; input.id = config.key; input.name = config.key; input.type = "date"; input.valueAsDate = config.value; return input; }; const buildTimePicker = (config) => { const input = document.createElement("input"); input.dataset.unitType = "TimePicker"; input.id = config.key; input.name = config.key; input.type = "time"; input.valueAsDate = config.value; return input; }; const buildWrapper = (controlFor) => { const div = document.createElement("div"); div.dataset.unitType = "Wrapper"; div.dataset.for = controlFor; return div; }; const buildLabel = (text, htmlFor = "") => { const label = document.createElement("label"); label.dataset.unitType = "Label"; label.textContent = text; label.htmlFor = htmlFor; return label; }; const buildFieldSet = (config) => { const fieldset = document.createElement("fieldset"); fieldset.dataset.unitType = "FieldSet"; if (config.legend) { const legend = document.createElement("legend"); legend.dataset.unitType = "FieldSetLegend"; legend.textContent = config.legend; fieldset.append(legend); } if (config.cols) { fieldset.dataset.cols = config.cols.toString(); } if (config.children.length) { render(fieldset, config.children); } return fieldset; }; const buildForm = (controls) => { const form = document.createElement("form"); form.dataset.unitType = "Form"; return render(form, controls); }; function render(target, controls) { for (const control of controls) { if (control.type === "FieldSet") { target.append(buildFieldSet(control)); } else { const wrapper = buildWrapper(control.type); if (control.type !== "CheckBox" && control.label) { const label = buildLabel(control.label || "", control.key); wrapper.append(label); } switch (control.type) { case "TextBox": { wrapper.append(buildTextBox(control)); target.append(wrapper); } break; case "TextArea": { wrapper.append(buildTextArea(control)); target.append(wrapper); } break; case "NumberBox": { wrapper.append(buildNumberBox(control)); target.append(wrapper); } break; case "SelectBox": { wrapper.append(buildSelectBox(control)); target.append(wrapper); } break; case "CheckBox": { wrapper.append(buildCheckBox(control)); target.append(wrapper); } break; case "RadioGroup": { wrapper.append(buildRadioGroup(control)); target.append(wrapper); } break; case "DatePicker": { wrapper.append(buildDatePicker(control)); target.append(wrapper); } break; case "TimePicker": { wrapper.append(buildTimePicker(control)); target.append(wrapper); } break; default: throw new Error("Form control is not yet supported"); } } } return target; } const renderUtils = { buildTextBox, buildTextArea, buildNumberBox, buildSelectBox, buildCheckBox, buildRadioGroup, buildDatePicker, buildTimePicker, buildFieldSet, buildForm }; function findPlugin(plugins, filterType) { return plugins.find((p) => p instanceof filterType); } class ValidationsPlugin { constructor(config) { this._parentElement = config?.parentElement || "div"; this._childElement = config?.childElement || "p"; this._messages = config?.messages; } run(input) { const validations = input; if (!validations) return; for (const val of validations) { const parent = val.control.getParentElement(); if (!parent) return; val.control.getValidationsElement()?.remove(); const errorsWrapper = document.createElement(this._parentElement); errorsWrapper.dataset.unitType = "ValidationsWrapper"; if (val.errors.length === 0) { parent.dataset.status = "valid"; } else { parent.dataset.status = "invalid"; } for (const error of val.errors) { if (error.required) { const errorMessage = document.createElement(this._childElement); errorMessage.dataset.unitType = "ValidationItem"; errorMessage.textContent = this._messages?.required || "Required field"; errorsWrapper.append(errorMessage); } if (error.requiredTrue) { const errorMessage = document.createElement(this._childElement); errorMessage.dataset.unitType = "ValidationItem"; errorMessage.textContent = this._messages?.requiredTrue || "Required field"; errorsWrapper.append(errorMessage); } if (error.email) { const errorMessage = document.createElement(this._childElement); errorMessage.dataset.unitType = "ValidationItem"; errorMessage.textContent = this._messages?.email || "Invalid email"; errorsWrapper.append(errorMessage); } if (error.min) { const errorMessage = document.createElement(this._childElement); errorMessage.dataset.unitType = "ValidationItem"; errorMessage.textContent = this._messages?.min?.replace("{0}", error.min.min).replace("{1}", error.min.current) || `Min: ${error.min.min}, Current: ${error.min.current}`; errorsWrapper.append(errorMessage); } if (error.max) { const errorMessage = document.createElement(this._childElement); errorMessage.dataset.unitType = "ValidationItem"; errorMessage.textContent = this._messages?.max?.replace("{0}", error.max.max).replace("{1}", error.max.current) || `Max: ${error.max.max}, Current: ${error.max.current}`; errorsWrapper.append(errorMessage); } if (error.minLength) { const errorMessage = document.createElement(this._childElement); errorMessage.dataset.unitType = "ValidationItem"; errorMessage.textContent = this._messages?.minLength?.replace("{0}", error.minLength.requiredLength).replace("{1}", error.minLength.currentLength) || `Min length: ${error.minLength.requiredLength}, Current length: ${error.minLength.currentLength}`; errorsWrapper.append(errorMessage); } if (error.maxLength) { const errorMessage = document.createElement(this._childElement); errorMessage.dataset.unitType = "ValidationItem"; errorMessage.textContent = this._messages?.maxLength?.replace("{0}", error.maxLength.requiredLength).replace("{1}", error.maxLength.currentLength) || `Max length: ${error.maxLength.requiredLength}, Current length: ${error.maxLength.currentLength}`; errorsWrapper.append(errorMessage); } parent?.append(errorsWrapper); } } } } class Form extends AbstractForm { constructor(args) { super(); this._controls = []; this.dataSource = args.dataSource; this.plugins = args.plugins || []; } get controls() { return this._controls; } getControl(key) { return this._controls.find((control) => control.key === key); } render(parent) { const form = renderUtils.buildForm(this.dataSource); parent.append(form); this._controls = this._flattenControls(this.dataSource); } reset() { this.controls.forEach((control) => { switch (control.type) { case "CheckBox": control.setValue(false); break; case "DatePicker": case "TimePicker": case "NumberBox": control.setValue(null); break; case "RadioGroup": control.reset(); break; default: control.setValue(""); } const parent = control.getParentElement(); delete parent.dataset.status; control.getValidationsElement()?.remove(); }); } validate() { const errors = []; const controls = this.controls; for (const control of controls) { if (control.validators.length === 0) continue; const parent = control.getParentElement(); delete parent?.dataset.status; control.getValidationsElement()?.remove(); const { errors: controlErrors } = control; if (controlErrors) { errors.push({ control, errors: controlErrors }); } } const plugin = findPlugin(this.plugins, ValidationsPlugin); plugin?.run(errors); return errors.length === 0 ? null : errors; } validateControl(key) { const errors = []; const control = this.controls.find((c) => c.key === key); if (!control) throw new Error(`${key} wasn't found`); const parent = control.getParentElement(); delete parent?.dataset.status; control.getValidationsElement()?.remove(); const { errors: controlErrors } = control; if (controlErrors) { errors.push({ control, errors: controlErrors }); } const plugin = findPlugin(this.plugins, ValidationsPlugin); plugin?.run(errors); return errors.length === 0 ? null : errors; } toJSON() { const formData = {}; this.controls.forEach((c) => { formData[c.key] = c.getValue(); }); return formData; } _flattenControls(controls, includeParents = false) { let result = []; for (const c of controls) { if (c.type === "FieldSet") { const children = c.children; if (includeParents) { result = [...result, c, ...this._flattenControls(children)]; } else { result = [...result, ...this._flattenControls(children)]; } } else { result.push(c); } } return result; } } const isEmptyInputValue = (value) => [null, void 0, ""].includes(value); const hasValidLength = (value) => value !== null && value !== void 0 && typeof value.length === "number"; function requiredValidator(control) { return isEmptyInputValue(control.getValue()) ? { required: true } : null; } function requiredTrueValidator(control) { return control.getValue() === true ? null : { requiredTrue: true }; } function emailValidator(control) { const controlValue = control.getValue(); if (isEmptyInputValue(controlValue)) { return null; } const EMAIL_REGEXP = /^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return EMAIL_REGEXP.test(controlValue) ? null : { email: true }; } function minValidator(min) { return (control) => { const controlValue = control.getValue(); if (isEmptyInputValue(controlValue)) { return null; } const value = parseFloat(controlValue); return !isNaN(value) && value < min ? { min: { min, current: value } } : null; }; } function maxValidator(max) { return (control) => { const controlValue = control.getValue(); if (isEmptyInputValue(controlValue)) { return null; } const value = parseFloat(controlValue); return !isNaN(value) && value > max ? { max: { max, current: value } } : null; }; } function minLengthValidator(minLength) { return (control) => { const controlValue = control.getValue(); if (isEmptyInputValue(controlValue) || !hasValidLength(controlValue)) { return null; } return controlValue.length < minLength ? { minLength: { requiredLength: minLength, currentLength: controlValue.length } } : null; }; } function maxLengthValidator(maxLength) { return (control) => { const controlValue = control.getValue(); if (isEmptyInputValue(controlValue) || !hasValidLength(controlValue)) { return null; } return controlValue.length > maxLength ? { maxLength: { requiredLength: maxLength, currentLength: controlValue.length } } : null; }; } class Validators { } Validators.required = (control) => requiredValidator(control); Validators.requiredTrue = (control) => requiredTrueValidator(control); Validators.email = (control) => emailValidator(control); Validators.min = (min) => minValidator(min); Validators.max = (max) => maxValidator(max); Validators.minLength = (minLength) => minLengthValidator(minLength); Validators.maxLength = (maxLength) => maxLengthValidator(maxLength); class AutoMapperPlugin { constructor(source, config) { this._source = source; this._config = config; } run() { const source = JSON.parse(JSON.stringify(this._source)); return this._mapFromSource(source); } _mapFromSource(source) { let controls = []; const { keys } = this._config; for (const item of source) { const controlType = item[keys.controlTypePropertyName]; switch (controlType) { case this._config.types["FieldSet"]: { const control = new FieldSet({ legend: item[keys.fieldSet.legend], cols: item[keys.fieldSet.cols], children: this._mapFromSource(item[keys.fieldSet.children]) }); controls = [...controls, control]; break; } case this._config.types["TextBox"]: { const control = new TextBox(item[keys.control.value], { key: item[keys.control.key], label: item[keys.control.label], placeholder: item[keys.control.placeholder], validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } case this._config.types["TextArea"]: { const control = new TextArea(item[keys.control.value], { key: item[keys.control.key], label: item[keys.control.label], placeholder: item[keys.control.placeholder], cols: item[keys.textAreaOptions.cols], rows: item[keys.textAreaOptions.rows], validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } case this._config.types["NumberBox"]: { const control = new NumberBox(item[keys.control.value], { key: item[keys.control.key], label: item[keys.control.label], min: item[keys.numberBoxOptions.min], max: item[keys.numberBoxOptions.max], validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } case this._config.types["SelectBox"]: { const options = item[keys.selectBoxOptions.options].map((o) => ({ value: o[keys.selectBoxOptions.value], text: o[keys.selectBoxOptions.text], meta: o[keys.selectBoxOptions.meta] })); const control = new SelectBox(item[keys.control.value], { key: item[keys.control.key], label: item[keys.control.label], options, validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } case this._config.types["CheckBox"]: { const control = new CheckBox(item[keys.control.value], { key: item[keys.control.key], label: item[keys.control.label], validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } case this._config.types["RadioGroup"]: { const items = item[keys.radioGroupOptions.items].map((o) => ({ value: o[keys.radioGroupOptions.value], label: o[keys.radioGroupOptions.label] })); const control = new RadioGroup(item[keys.control.value], { key: item[keys.control.key], text: item[keys.control.label], items, validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } case this._config.types["DatePicker"]: { const control = new DatePicker(item[keys.control.value], { key: item[keys.control.key], label: item[keys.control.label], validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } case this._config.types["TimePicker"]: { const control = new TimePicker(item[keys.control.value], { key: item[keys.control.key], label: item[keys.control.label], validators: this._mapValidators(item[keys.control.key], item[keys.control.validators]) }); controls.push(control); break; } default: { throw new Error(`${item[keys.control.key]} -> ${controlType} was not recognized`); } } } return controls; } _mapValidators(controlKey, rules) { if (rules?.length === 0 || rules === null || rules === void 0) return []; if (!Array.isArray(rules)) throw new Error(`${controlKey}: Invalid structure for validators. It must be an array of items`); const { validators: validatorKeys } = this._config.keys; const validators = []; for (const rule of rules) { const ruleType = rule[validatorKeys["name"]]; switch (ruleType) { case "required": validators.push(Validators.required); break; case "requiredTrue": validators.push(Validators.requiredTrue); break; case "email": validators.push(Validators.email); break; case "min": { const valueKey = validatorKeys["value"]; if (!valueKey) throw new Error(`${controlKey}: a value must be provided for the min validator`); const value = rule[valueKey]; if (typeof value !== "number") throw new Error(`${controlKey}: type ${typeof value} is invalid for the min validator`); validators.push(Validators.min(value)); break; } case "max": { const valueKey = validatorKeys["value"]; if (!valueKey) throw new Error(`${controlKey}: a value must be provided for the max validator`); const value = rule[valueKey]; if (typeof value !== "number") throw new Error(`${controlKey}: type ${typeof value} is invalid for the max validator`); validators.push(Validators.max(value)); break; } case "minLength": { const valueKey = validatorKeys["value"]; if (!valueKey) throw new Error(`${controlKey}: a value must be provided for the minLength validator`); const value = rule[valueKey]; if (typeof value !== "number") throw new Error(`${controlKey}: type ${typeof value} is invalid for the minLength validator`); validators.push(Validators.minLength(value)); break; } case "maxLength": { const valueKey = validatorKeys["value"]; if (!valueKey) throw new Error(`${controlKey}: a value must be provided for the maxLength validator`); const value = rule[valueKey]; if (typeof value !== "number") throw new Error(`${controlKey}: type ${typeof value} is invalid for the maxLength validator`); validators.push(Validators.maxLength(value)); break; } default: throw new Error(`Invalid validator ${JSON.stringify(rule)} for "${controlKey}" control. The configured name is "${validatorKeys["name"]}"`); } } return validators; } } class EventsPlugin { run({ targetKey, attachmentType, on, runFn }) { switch (attachmentType) { case "single": document.querySelector(`#${targetKey}`)?.addEventListener(on, runFn); break; case "multiple": document.querySelectorAll(`[name="${targetKey}"]`)?.forEach((item) => item.addEventListener(on, runFn)); break; default: throw new Error("Attachment type has not been implemented yet"); } } } export { AbstractControl, AutoMapperPlugin, CheckBox, DatePicker, EventsPlugin, FieldSet, Form, NumberBox, RadioGroup, SelectBox, TextArea, TextBox, TimePicker, ValidationsPlugin, Validators, findPlugin };