@ekisa-cdk/forms
Version:
🛠️ Easily build & integrate dynamic forms
893 lines (874 loc) • 29.2 kB
JavaScript
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 };