├── .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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------