├── .gitignore ├── src ├── components │ ├── numberInput.js │ ├── index.js │ ├── customElement.js │ ├── dropdownElement.js │ └── inputElement.js ├── index.js ├── tools │ ├── eventBuilder.js │ ├── booleanProcessor.js │ └── validator.js ├── styles.scss ├── utils │ ├── domHelpers.js │ └── index.js ├── config.js └── generator.js ├── README.md ├── index.html └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .vscode 4 | .cache -------------------------------------------------------------------------------- /src/components/numberInput.js: -------------------------------------------------------------------------------- 1 | import InputElement from "./inputElement"; 2 | 3 | export default class NumberInputElement extends InputElement { 4 | constructor() { 5 | super({ 6 | type: "number" 7 | }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dynamic-form-web-components 2 | An example of creating Config Driven UI by using Custom Components using the web apis and vanilla JS. 3 | 4 | ![mar-08-2019 15-45-14](https://user-images.githubusercontent.com/4329912/54022768-4aba7680-41b9-11e9-953a-bcba340ca7c6.gif) 5 | 6 | # Installation 7 | - Clone Repo 8 | - Run `npm i` 9 | - Run `npm start` 10 | 11 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | import InputElement from "./inputElement"; 2 | import NumberInputElement from "./numberInput"; 3 | import DropdownElement from "./dropdownElement"; 4 | 5 | export const elementsConfig = { 6 | "app-input": InputElement, 7 | "app-number-input": NumberInputElement, 8 | "app-dropdown": DropdownElement 9 | }; 10 | 11 | for (const [key, val] of Object.entries(elementsConfig)) { 12 | customElements.define(key, val); 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import "./styles.scss"; 2 | import Generator, { createComponents } from "./generator.js"; 3 | import { CONFIG } from "./config"; 4 | 5 | const genContainer = document.getElementById("generator"); 6 | 7 | const formValuesContainer = document.querySelector("#formValues #vContainer"); 8 | 9 | const renderFromValuesContainer = () => { 10 | formValuesContainer.innerText = JSON.stringify(formValues, null, 4); 11 | }; 12 | 13 | const formValues = { 14 | firstName: "Param", 15 | lastName: "Singh", 16 | city: "Bangalore", 17 | state: "Karnataka" 18 | }; 19 | 20 | renderFromValuesContainer(); 21 | 22 | Generator.createComponentGenerator( 23 | CONFIG, 24 | genContainer, 25 | formValues, 26 | renderFromValuesContainer 27 | ); 28 | -------------------------------------------------------------------------------- /src/tools/eventBuilder.js: -------------------------------------------------------------------------------- 1 | export default (eventHandlers, formValues, formElementsMap) => { 2 | const handleEvent = eventHandler => { 3 | switch (eventHandler.type) { 4 | case "SET_VALUE": 5 | return value => { 6 | formValues[eventHandler.fieldId] = eventHandler.valueField 7 | ? formValues[eventHandler.valueField] 8 | : eventHandler.value === "SELF" 9 | ? value 10 | : eventHandler.value; 11 | formElementsMap[eventHandler.fieldId].value = 12 | formValues[eventHandler.fieldId]; 13 | }; 14 | } 15 | }; 16 | if (Array.isArray(eventHandlers)) { 17 | return eventHandlers.map(eventHandler => handleEvent(eventHandler)); 18 | } else { 19 | return handleEvent(eventHandlers); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/tools/booleanProcessor.js: -------------------------------------------------------------------------------- 1 | export const OPERATORS = { 2 | "=": (a, b) => a === b, 3 | "!=": (a, b) => a !== b, 4 | "<": (a, b) => a < b, 5 | "<=": (a, b) => a <= b, 6 | ">": (a, b) => a > b, 7 | ">=": (a, b) => a >= b 8 | }; 9 | 10 | export default (config, formValues) => { 11 | const { type, operator, fieldId, fieldValue } = config; 12 | if (type === "COMPOUND") { 13 | return operator === "AND" 14 | ? conditions.reduce( 15 | (accumulator, cond) => accumulator && booleanProcessor(cond), 16 | true 17 | ) 18 | : conditions.reduce( 19 | (accumulator, cond) => accumulator || booleanProcessor(cond), 20 | false 21 | ); 22 | } else { 23 | return OPERATORS[operator](formValues[fieldId], fieldValue); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dynamic Dory 5 | 6 | 10 | 14 | 15 | 16 | 17 |
18 | 23 |
24 |
25 |
Form Values
26 |
27 |
28 |
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | body { 2 | --input-field-margin-bottom: 20px; 3 | --input-field-margin-top: 20px; 4 | --error-red: #e74c3c; 5 | font-family: sans-serif; 6 | .brand-logo { 7 | font-size: 20px; 8 | padding-left: 20px; 9 | } 10 | 11 | .flex { 12 | display: flex; 13 | align-items: center; 14 | } 15 | .block { 16 | display: block; 17 | } 18 | .select-box { 19 | min-width: 200px; 20 | } 21 | #invoice { 22 | display: flex; 23 | width: 40vw; 24 | margin: 70px auto; 25 | flex-direction: column; 26 | .invoices-list { 27 | } 28 | } 29 | .editableComponent { 30 | i.material-icons { 31 | cursor: pointer; 32 | margin-left: 8px; 33 | font-size: 14px; 34 | } 35 | } 36 | #generator { 37 | width: 80vw; 38 | margin: 70px auto; 39 | padding: 20px; 40 | > span { 41 | margin: 10px 0; 42 | } 43 | } 44 | #formValues { 45 | @extend #generator; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamic-form", 3 | "version": "1.0.0", 4 | "description": "An example of creating Config Driven UI by using Custom Components using the web apis and vanilla JS. ", 5 | "main": "index.html", 6 | "scripts": { 7 | "start": "parcel index.html --open", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "materialize-css": "1.0.0" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "7.2.0", 15 | "@types/materialize-css": "1.0.6", 16 | "parcel-bundler": "^1.11.0", 17 | "sass": "^1.17.2" 18 | }, 19 | "keywords": [ 20 | "web", 21 | "components", 22 | "javascript", 23 | "custom", 24 | "elements", 25 | "html", 26 | "config", 27 | "driven", 28 | "development", 29 | "reactive", 30 | "react", 31 | "angular", 32 | "vue", 33 | "vanilla", 34 | "dynamic", 35 | "form", 36 | "configuration", 37 | "based" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/domHelpers.js: -------------------------------------------------------------------------------- 1 | export const createNode = ( 2 | nodeName, 3 | { classes = [], content = "", attrs = {}, children, events = {}, props = {} } 4 | ) => { 5 | const el = document.createElement(nodeName); 6 | classes.forEach(c => el.classList.add(c)); 7 | for (let [key, value] of Object.entries(attrs)) { 8 | el.setAttribute(key, value); 9 | } 10 | for (let [key, value] of Object.entries(props)) { 11 | el[key] = value; 12 | } 13 | for (let [eventName, eventHandler] of Object.entries(events)) { 14 | el.addEventListener(eventName, eventHandler); 15 | } 16 | if (content) { 17 | el.appendChild(document.createTextNode(content)); 18 | } 19 | if (children) { 20 | if (Array.isArray(children)) { 21 | const frag = document.createDocumentFragment(); 22 | children.forEach(c => frag.appendChild(c)); 23 | el.appendChild(frag); 24 | } else { 25 | el.appendChild(children); 26 | } 27 | } 28 | 29 | return el; 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export const isObject = a => !!a && a.constructor.name === "Object"; 2 | 3 | export const isNonEmptyObject = a => isObject(a) && !!Object.keys(a).length; 4 | 5 | export const shallowCompareObjects = (a, b) => { 6 | if (!!a && !!b && (!isObject(a) || !isObject(b))) { 7 | const aKeys = Object.keys(a); 8 | const bKeys = Object.keys(b); 9 | 10 | if (aKeys.length !== bKeys.length) { 11 | return false; 12 | } 13 | for (const key of aKeys) { 14 | if (!Object.is(a[key], b[key])) { 15 | return false; 16 | } 17 | } 18 | return true; 19 | } else { 20 | return Object.is(a, b); 21 | } 22 | }; 23 | 24 | export const shallowCompare = (a, b) => { 25 | if (typeof a !== typeof b) { 26 | return false; 27 | } 28 | if (Array.isArray(a) && Array.isArray(b)) { 29 | if (a.length !== b.length) return false; 30 | for (let i = 0; i < a.length; i++) { 31 | if (!Object.is(a, b)) return false; 32 | } 33 | } 34 | return shallowCompareObjects(a, b); 35 | }; 36 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export const CONFIG = [ 2 | { 3 | id: "firstName", 4 | type: "TEXT", 5 | text: "First Name" 6 | }, 7 | { 8 | id: "lastName", 9 | type: "TEXT", 10 | text: "Last Name" 11 | }, 12 | { 13 | id: "occupation", 14 | type: "TEXT", 15 | text: `Occupation`, 16 | validations: [ 17 | { 18 | type: "LENGTH", 19 | max: 20, 20 | min: 3 21 | }, 22 | { 23 | type: "REQUIRED" 24 | } 25 | ] 26 | }, 27 | { 28 | id: "age", 29 | type: "NUMBER", 30 | text: `Age`, 31 | validations: [ 32 | { 33 | type: "REQUIRED" 34 | }, 35 | { 36 | type: "PATTERN", 37 | expression: "^\\d+$" 38 | }, 39 | { 40 | type: "RANGE", 41 | min: 18, 42 | max: 60 43 | } 44 | ] 45 | }, 46 | { 47 | id: "city", 48 | type: "DROPDOWN", 49 | text: "City", 50 | dataSource: ["Delhi", "Mumbai", "Bangalore"] 51 | }, 52 | { 53 | id: "state", 54 | type: "DROPDOWN", 55 | text: "State", 56 | dataSource: ["Karnataka", "Maharashtra", "Delhi"], 57 | events: { 58 | change: [ 59 | { 60 | fieldId: "occupation", 61 | type: "SET_VALUE", 62 | valueField: "city", 63 | when: { 64 | fieldId: "city", 65 | fieldValue: "Delhi", 66 | type: "comparison", 67 | operator: "!=" 68 | } 69 | } 70 | ] 71 | } 72 | } 73 | ]; 74 | 75 | export const COMPONENTS_MAPPING = { 76 | TEXT: "app-input", 77 | DROPDOWN: "app-dropdown", 78 | NUMBER: "app-number-input" 79 | }; 80 | -------------------------------------------------------------------------------- /src/tools/validator.js: -------------------------------------------------------------------------------- 1 | export default (validationConfig, fieldId, formValues) => { 2 | const fieldValue = formValues[fieldId]; 3 | let errors = {}; 4 | validationConfig.forEach(validation => { 5 | switch (validation.type) { 6 | case "REQUIRED": 7 | if (!fieldValue) { 8 | errors["REQUIRED"] = "Required"; 9 | } 10 | break; 11 | case "RANGE": 12 | { 13 | const { min, max } = validation; 14 | if (!(min <= fieldValue && fieldValue <= max)) { 15 | errors["RANGE"] = "Out of Range"; 16 | } 17 | } 18 | break; 19 | case "LENGTH": 20 | { 21 | const { min = 0, max } = validation; 22 | if (!(min <= fieldValue.length && fieldValue.length <= max)) { 23 | errors["LENGTH"] = "Invalid Length"; 24 | } 25 | } 26 | break; 27 | case "PATTERN": 28 | { 29 | const { expression } = validation; 30 | if (!new RegExp(expression).test(fieldValue)) { 31 | errors["PATTERN"] = `Doesn't match the pattern`; 32 | } 33 | } 34 | break; 35 | case "COMPARISON": 36 | { 37 | if ( 38 | !booleanProcessor( 39 | { 40 | ...validation, 41 | fieldValue 42 | }, 43 | formValues 44 | ) 45 | ) { 46 | errors["COMPARISON"] = `Doesn't meet the conditions`; 47 | } 48 | } 49 | break; 50 | case "CUSTOM": { 51 | const { validator: customValidator, customMessage } = validation; 52 | if (!customValidator(fieldValue)) { 53 | errors["CUSTOM"] = customMessage || "Validation Failed"; 54 | } 55 | } 56 | default: 57 | throw "Please pass a valid validation type"; 58 | } 59 | }); 60 | 61 | return errors; 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/customElement.js: -------------------------------------------------------------------------------- 1 | export default class CustomElement extends HTMLElement { 2 | constructor(options = {}) { 3 | super(); 4 | this._value = ""; 5 | this.shadowEl = this.attachShadow({ mode: "open" }); 6 | this.options = options; 7 | if (options.props) { 8 | const propDescriptors = {}; 9 | for (const prop of options.props) { 10 | propDescriptors[prop] = { 11 | get() { 12 | const attrValue = this.getAttribute(prop); 13 | return this.getJSONParsedValue(attrValue); 14 | }, 15 | set(value) { 16 | if (prop === "value") { 17 | this.dispatchEvent( 18 | new CustomEvent("change", { 19 | detail: value 20 | }) 21 | ); 22 | } else { 23 | this.dispatchEvent( 24 | new CustomEvent( 25 | prop === "value" ? `changed` : `${prop}Changed`, 26 | { 27 | detail: value 28 | } 29 | ) 30 | ); 31 | } 32 | if (value && typeof value === "object") { 33 | this.setAttribute(prop, JSON.stringify(value)); 34 | } else { 35 | this.setAttribute(prop, value); 36 | } 37 | } 38 | }; 39 | } 40 | Object.defineProperties(this, propDescriptors); 41 | } 42 | } 43 | getJSONParsedValue(value) { 44 | let parsedValue; 45 | try { 46 | parsedValue = JSON.parse(value); 47 | } catch (e) { 48 | parsedValue = value; 49 | } 50 | return parsedValue; 51 | } 52 | connectedCallback() { 53 | const inputFieldHolder = this.shadowEl.querySelector(".input-field"); 54 | if (inputFieldHolder) { 55 | inputFieldHolder.insertAdjacentHTML( 56 | "afterbegin", 57 | `` 58 | ); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/dropdownElement.js: -------------------------------------------------------------------------------- 1 | import CustomElement from "./customElement"; 2 | import { shallowCompare } from "../utils"; 3 | 4 | export default class DropdownElement extends CustomElement { 5 | static get observedAttributes() { 6 | return ["value", "datasource"]; 7 | } 8 | 9 | constructor() { 10 | super({ 11 | props: ["datasource", "value"] 12 | }); 13 | this.shadowEl.innerHTML = ` 14 | 34 |
35 | 37 |
38 | `; 39 | 40 | this.selectEl = this.shadowEl.querySelector("select"); 41 | } 42 | connectedCallback() { 43 | super.connectedCallback(); 44 | this.changeEventListener = e => { 45 | this.value = e.target.value; 46 | }; 47 | this.selectEl.addEventListener("change", this.changeEventListener); 48 | } 49 | 50 | attributeChangedCallback(attrName, oldValue, newValue) { 51 | const val = this.getJSONParsedValue(newValue); 52 | const oldVal = this.getJSONParsedValue(oldValue); 53 | if (shallowCompare(oldVal, val)) return; 54 | switch (attrName) { 55 | case "datasource": 56 | { 57 | // TODO: Take id field from the consumer and assign value field as optionValue[id] in case of non primitive optionValue value. 58 | this.selectEl.innerHTML = val.map( 59 | optionValue => 60 | `` 61 | ); 62 | this.selectEl.value = this.value; 63 | } 64 | break; 65 | case "value": 66 | this.selectEl.value = val; 67 | } 68 | } 69 | 70 | disconnectedCallback() { 71 | this.selectEl.removeEventListener("change", this.changeEventListener); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/generator.js: -------------------------------------------------------------------------------- 1 | import "./components"; 2 | import validator from "./tools/validator"; 3 | import eventHandlerBuilder from "./tools/eventBuilder"; 4 | import { COMPONENTS_MAPPING } from "./config"; 5 | import { createNode } from "./utils/domHelpers"; 6 | 7 | export default class Generator { 8 | static createComponentGenerator(...args) { 9 | return new Generator(...args); 10 | } 11 | 12 | constructor( 13 | componentsConfig, 14 | containerEl, 15 | formValues, 16 | formValueChangeCallback 17 | ) { 18 | this.formElementsMap = {}; 19 | this.formValidations = {}; 20 | const formValuesProxied = new Proxy(formValues, { 21 | set(obj, prop, value) { 22 | obj[prop] = value; 23 | formValueChangeCallback(); 24 | return true; 25 | } 26 | }); 27 | this.createComponents(componentsConfig, containerEl, formValuesProxied); 28 | } 29 | 30 | renderComp(compConfig, formValues) { 31 | const elementName = COMPONENTS_MAPPING[compConfig.type]; 32 | const events = { 33 | change: e => { 34 | formValues[compConfig.id] = e.detail; 35 | if (compConfig.validations) { 36 | const errors = validator( 37 | compConfig.validations, 38 | compConfig.id, 39 | formValues 40 | ); 41 | this.formValidations[compConfig.id] = errors; 42 | this.formElementsMap[compConfig.id].validations = errors; 43 | } 44 | } 45 | }; 46 | if (compConfig.events) { 47 | for (const [eventName, eventHandlers] of Object.entries( 48 | compConfig.events 49 | )) { 50 | let existingHandler; 51 | if (events[eventName]) { 52 | existingHandler = events[eventName]; 53 | } 54 | 55 | events[eventName] = e => { 56 | existingHandler && existingHandler(e); 57 | const evaulatedEventHandlers = eventHandlerBuilder( 58 | eventHandlers, 59 | formValues, 60 | this.formElementsMap 61 | ); 62 | Array.isArray(evaulatedEventHandlers) 63 | ? evaulatedEventHandlers.forEach(evaulatedEventHandler => 64 | evaulatedEventHandler(e.detail) 65 | ) 66 | : evaulatedEventHandlers(e.detail); 67 | }; 68 | } 69 | } 70 | const node = createNode(elementName, { 71 | props: { 72 | value: formValues[compConfig.id] || "", 73 | datasource: compConfig.dataSource || [], 74 | validations: this.formValidations[compConfig.id] || {} 75 | }, 76 | attrs: { 77 | placeholder: compConfig.text || "", 78 | name: compConfig.id, 79 | labelText: compConfig.text, 80 | ...compConfig.attrs 81 | }, 82 | events, 83 | children: (compConfig.children || []).map(childConfig => 84 | renderComp(childConfig, formValues) 85 | ) 86 | }); 87 | 88 | this.formElementsMap[compConfig.id] = node; 89 | return node; 90 | } 91 | 92 | createComponents(conf, containerEl, formValues) { 93 | for (const compConfig of conf) { 94 | const element = this.renderComp(compConfig, formValues); 95 | !!element ? containerEl.appendChild(element) : void 0; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/components/inputElement.js: -------------------------------------------------------------------------------- 1 | import CustomElement from "./customElement"; 2 | import { shallowCompare, isNonEmptyObject } from "../utils"; 3 | import { createNode } from "../utils/domHelpers"; 4 | 5 | export default class InputElement extends CustomElement { 6 | static get observedAttributes() { 7 | return ["value", "validations"]; 8 | } 9 | 10 | constructor({ type = "text" } = {}) { 11 | super({ 12 | props: InputElement.observedAttributes 13 | }); 14 | 15 | this.shadowEl.innerHTML = ` 16 | 54 |
55 | 56 | 58 |
59 | `; 60 | this.inputEl = this.shadowRoot.querySelector("input"); 61 | } 62 | 63 | connectedCallback() { 64 | super.connectedCallback(); 65 | this.inputEl.value = this.value; 66 | this.inputEventListener = e => { 67 | this.value = e.target.value; 68 | }; 69 | this.inputEl.addEventListener("input", this.inputEventListener); 70 | } 71 | 72 | attributeChangedCallback(attrName, oldValue, newValue) { 73 | const jsonParsedOldVal = this.getJSONParsedValue(oldValue); 74 | const jsonParsedNewVal = this.getJSONParsedValue(newValue); 75 | if (attrName === "value" && oldValue !== newValue) { 76 | this.value = newValue; 77 | if (this.inputEl) { 78 | this.inputEl.value = newValue; 79 | } 80 | } 81 | if ( 82 | attrName === "validations" && 83 | !shallowCompare(jsonParsedOldVal, jsonParsedNewVal) 84 | ) { 85 | if (this.inputEl) { 86 | if (isNonEmptyObject(jsonParsedNewVal)) { 87 | this.inputEl.classList.add("error"); 88 | this.renderErrors(jsonParsedNewVal); 89 | } else { 90 | this.inputEl.classList.remove("error"); 91 | this.renderErrors(); 92 | } 93 | } 94 | } 95 | } 96 | renderErrors(errorsMap = null) { 97 | const errorsUl = this.shadowRoot.querySelector(".errors-list"); 98 | if (errorsUl) { 99 | errorsUl.innerHTML = ""; 100 | if (!errorsMap) { 101 | return; 102 | } 103 | const liFragment = document.createDocumentFragment(); 104 | for (const key of Object.keys(errorsMap)) { 105 | liFragment.appendChild(createNode("li", { content: errorsMap[key] })); 106 | } 107 | errorsUl.appendChild(liFragment); 108 | } 109 | } 110 | setAttribute(name, value) { 111 | super.setAttribute(name, value); 112 | this.inputEl && this.inputEl.setAttribute(name, value); 113 | } 114 | 115 | disconnectedCallback() { 116 | this.inputEl.removeEventListener("change", this.inputEventListener); 117 | } 118 | } 119 | --------------------------------------------------------------------------------