Source: form.js

"use strict";

/**
 * Form holds all queries for a certain form, initiates valiadation, is able to make a valitation summary and takes care of things like title and form wrapping.
 */
class Form {
	/**
	 * Create Form. 
	 * @param {Object} data - An object defining the properties of a Form.
	 * @param {dom} data.baseElement - Tells the Form which DOM object it should use to insert itself.
	 * @param {string} [data.title] - Defines a title content for the new Form.
	 * @param {string} [data.titleTagName] - Defines a title tag name, like for example h1, h2, h3 or span, to be used in the new Form.
	 * @param {boolean} [data.allowCorrections] - Tells whether corrections are enforced prior submitting. If set to true the form will only submit itself once all data is filled in correctly. If set to false the form will submit itself even with wrong answers set.
	 * @param {boolean} [data.scrollToError] - If set to true the Form will scroll to the first wrong answer prior to submitting. This only works in combination with "data.allowCorrections = true".
	 * @param {string} [data.type] - If set to "showSummary" it will display its own basic summary screen with the list of all input items and their answers after submit is complete.
	 * @param {boolean} [data.customSubmit] - If set to true, a custom submit will be used instead of the default one.
	 * @param {dom} [data.submitBtn] - If set and customSubmit set to true the passed one will be used instead.
	 * @param {function} [data.submitEvent] - If defined the specified function will be called upon submiting the event with an event passed as an argument and this set to be an instance of this object.
	 * @param {string} [data.submit] - Defines a value shown on a submit button.
	 * @param {string} [data.submitUrl] - Defines a url on which a post XMLHttpRequest should be submited using POST method.
	 * @param {(string|function)} [data.draggable] - Defines one of the default draggable plugins (jQuery|interact) or a custom one by passing a function. Default is interact.js.
	 * @param {number} [index] - Defines index of the current form.
	 */
	constructor(data, index) {
		this.data = data || {};
		this.queries = [];
		this.sections = [];
		
		this.formIndex = index || null;
		
		this.scrollToError = this.data.scrollToError || false;
		
		/* Create container and embedd it into the baseElement or baseForm */
		this.container = document.createElement("div");
		this.container.classList.add("dt-form-container");
		if(this.data.baseElement) {
			this.data.baseElement.appendChild(this.container);
			this.baseForm = document.createElement("form");
			this.container.appendChild(this.baseForm);
			this.title = this.createTitle();
		}
		else if(this.data.baseForm) {
			this.baseForm = this.data.baseForm;
			this.baseForm.parentNode.insertBefore(this.container, this.baseForm.nextSibling);
			this.container.appendChild(this.baseForm);
		}
		else {
			throw "Neither data.baseElement nor data.baseForm has been defined. There is no element to place this Form to.";
		}
		
		/* Use custom submit button or create one */
		if(this.data.customSubmit !== true) {
			this.submit = this.createSubmit();
		}
		else if(this.data.submitBtn) {
			this.submit = this.data.submitBtn;
		}
		
		/* Set custom event if one is submitted */
		if(typeof this.data.submitEvent !== undefined && typeof this.data.submitEvent === "function") {
			this.setOnSubmitEvent(this.data.submitEvent);
		}
		
		/* Default submit event */
		this.setOnSubmitEvent(() => {
			this.submitProcess();
		});
	}
	
	/**
	 * Get section container, that is this.baseForm which has been either passed upon initialization or created automatically.
	 * @return {dom} - The baseForm DOM element.
	 */
	get sectionContainer() {
		return this.baseForm;
	}
	
	/**
	 * Add query to DOM on this form
	 * @param {QueryItem} queryItem - A QueryItem to be logged in the internal structure and appended to the form element.
	 * @return {(QueryItem|false)} - Returns the registerd QueryItem or false if no QueryItem was passed.
	 */
	addQuery(queryItem) {
		if(queryItem) {
			/* Create new section container for the query to sit in */
			let sectionContainer = document.createElement("div");
			sectionContainer.classList.add("dt-section-container", "dt-section-" + queryItem.queryData.index, queryItem.queryData.typeVal);
			
			/* If we created our own built in submit, then insert new query before it, otherwise don't care and just append to the form */
			if(this.submit) {
				this.submit.parentNode.insertBefore(sectionContainer, this.submit);
			}
			else {
				this.baseForm.appendChild(sectionContainer);
			}
			
			queryItem.queryData.sectionContainer = sectionContainer;
			this.queries[queryItem.queryData.index] = queryItem.write();
			
			this.sections.push(sectionContainer);
			return this.queries[queryItem.queryData.index];
		}
		return false;
	}
	
	/**
	 * Create a new query (QueryItem + QueryDataBlock) on this form
	 * @param {(QueryDataBlock|Object)} queryData - A QueryDataBlock to be used with the new QueryItem or an Object convertible to QueryDataBlock.
	 * @see {@link QueryDataBlock} for more information on the required Object structure.
	 * @return {(QueryItem|false)} - Returns the newly created QueryItem or false if no QueryData was passed.
	 */
	createQuery(queryData) {
		if(queryData) {
			/* Tell if passed data are already an instance of QueryDataBlock class, if not try to convert them */
			if(!(queryData instanceof QueryDataBlock)) {
				/* Converting query data to QueryDataBlock object */
				try {
					/* Create new QuertDataBlock and pass queryData to it. */
					queryData = new QueryDataBlock(queryData);
					/* Set new query index, based on the count of queries already present */
					queryData.index = this.queries.length;
					/* Pass Form index */
					queryData.formIndex = this.formIndex;
				}
				catch(error) {
					throw "Can't convert passed queryData Object inro QueryDataBlock.";
				}
			}
			
			queryData.baseForm = this.baseForm;
			queryData.draggable = this.data.draggable;
			
			return new QueryItem(queryData);
		}
		return false;
	}
	
	/**
	 * Adds a new QueryItem to the Form
	 * @param {(QueryDataBlock|Object)} queryData - A QueryDataBlock to be used with the new QueryItem or an Object convertible to QueryDataBlock, @see QueryDataBlock.
	 * @return {(QueryItem|false)} - Returns the registerd QueryItem or false if no QueryItem was passed.
	 */
	newQueryItem(queryData) {
		let queryItem = this.createQuery(queryData);
		if(queryItem) {
			let newQuery = this.addQuery(queryItem);
			if(newQuery) {
				return newQuery;
			}
		}
		return false;
	}
	
	/* Returns an object with validation data summary containing an nentry for each QueryItem */
	/**
	 * Gets validation summary object containing an array of relevant form submit data.
	 * @return {object} - An object with the following structure: { items: [allValid, title, subtitle, type, queryValues], itemsLength }
	 */
	validationSummary() {
		let summary = {
			itemsLengt: this.queries.length,
			items: []
		};
		for(let i = 0; i < this.queries.length; i++) {
			let validationData = this.queries[i].validate();
			summary.items[i] = {
				allValid: validationData.allValid,
				title: this.queries[i].queryData.title,
				subtitle: this.queries[i].queryData.subtitle,
				type: this.queries[i].queryData.type,
				queryValues: this.queries[i].value
			}
		}
		return summary;
	};
	
	/**
	 * Checks whether the form is valid or not.
	 * @return {boolean} - Return true if all input data is valid, false if not.
	 */
	isValid() {
		for(let i = 0; i < this.queries.length; i++) {
			let validationData = this.queries[i].validate();
			if(!validationData.allValid) {
				return false;
			}
		}		
		return true;
	}
	
	/**
	 * Get first invalid input item.
	 * @param {boolean} passAll - If set to false it won't go through each item and break on first error found.
	 * @return {(Object|null)} - An object with the first invalid InputItem from the beginning and numeric queryIndex, or null if all items are valid.
	 */
	getFirstInvalidItem(passAll) {
		let firstInvalid = null;
		for(let i = 0; i < this.queries.length; i++) {
			let validationData = this.queries[i].validate();
			if(!validationData.allValid) {
				if(firstInvalid === null) {
					firstInvalid = {
						queryIndex: i,
						inputItem: validationData.inputItems[0]
					};
				}
				if(passAll === false) {
					break;
				}
			}
		}		
		return firstInvalid;
	}
	
	/**
	 * Validates all form fields by calling each QueryItem validation.
	 * In addition scrolls to the first invalid element if there is such.
	 * @return {boolean} - Returns true if all field are valid, false if not.
	 */
	validate() {
		let firstInvalidItem = this.getFirstInvalidItem(this.data.allowCorrections);
		
		if(this.scrollToError && firstInvalidItem !== null) {
			this.queries[firstInvalidItem.queryIndex].container.scrollIntoView(true);
			return false;
		}
		return firstInvalidItem === null;
	};

	/**
	 * Returns an array of form input values.
	 * @return {string[]} - Array of values for each registered each QueryItem.
	 */
	queryValues() {
		let queryValues = [];
		for(let i = 0; i < this.queries.length; i++) {
			queryValues.push(this.queries[i].value);
		}
		return queryValues;
	};
	
	/**
	 * Serializes form data and returns them in a string.
	 * @return {string} - Serialized Form data.
	 */
	serialize() {
		let queryValues = this.queryValues();
		let string = "";
		for(let i = 0; i < queryValues.length; i++) {
			for(let j = 0; j < queryValues[i].length; j++) {
				if(queryValues[i][j].values != "") {
					if(j != 0) {
						string += ";";
					}
					else {
						if(i != 0 && string.length > 0) {
							string += "&";
						}
						string += queryValues[i][j].name + "=";
					}	
					string += queryValues[i][j].values;
				}
			}
		}
		return string;
	};
	
	/**
	 * Creates form title tag.
	 * @return {dom} - The newly created form DOM title tag.
	 */
	createTitle() {
		let title = this.data.title ? this.data.title : "Form";
		let tagName = this.data.titleTagName ? this.data.titleTagName : "h2";
		
		let titleTag = document.createElement(tagName);
		titleTag.innerHTML = title;
		titleTag.classList.add("dt-main-title");
		this.baseForm.appendChild(titleTag);
		return titleTag;
	};
	
	/**
	 * Creates form submit tag.
	 * @return {dom} - The newly created form DOM submit button.
	 */
	createSubmit() {
		let submitText = this.data.submit ? this.data.submit : "Submit";
		this.submitTag = document.createElement("input");
		this.submitTag.classList.add("dt-submit");
		this.submitTag.setAttribute("type", "submit");
		this.submitTag.value = submitText;
		this.baseForm.appendChild(this.submitTag);
		return this.submitTag;
	}
	
	/**
	 * Define what should be called upon submitting the form using submit event.
	 * Once the callback function is called it will receive the event via its parameter and this set to the instance of this Form class.
	 * Always prevents default form submit event.
	 * @param {function} call - A function that should be called upon submitting the form.
	 * @return {boolean} - True if this.baseForm and call was defined and event set up, false if not.
	 */
	setOnSubmitEvent(callback) {
		if(this.baseForm && typeof callback === "function") {
			this.baseForm.addEventListener("submit", (event) => {
				event.preventDefault();
				callback.call(this, event);
			});
			return true;
		}
		return false;
	}
	
	/**
	 * Walks trhough the full submit form process taking care of corrections if allowed and posting data using POST method to a specified url.
	 * @return {boolean} - True if submit process completed or false if there were validation issues and corrections were allowed.
	 */
	submitProcess() {
		let next = false;
		if(this.data.allowCorrections === false) {
			next = true;
		}
		else {
			if(this.validate()) {
				next = true;
			}
		}
		
		if(next) {
			if(this.data.submitUrl) {
				CommonFormFunctions.postData(this.data.submitUrl, this.serialize()).then((response) => {
					this.container.removeChild(this.baseForm);
				}, function(error) {
					console.error("Error: " + error);
				});
			}
			
			if(this.data.type === "showSummary") {
				this.showSubmitSummary()
			}				
		}
		
		return next;
	}
	
	/**
	 * Show a simple built-in form submit summary in place of the original form.
	 * @param {boolean} [keepForm] - If set to true the form will not be remove from the DOM and the submit summary will display below it.
	 * @return {Form} - Instance of this Form.
	 */
	showSubmitSummary(keepForm) {
		if(keepForm !== true) {
			this.container.removeChild(this.baseForm);
		}
		
		let summary = this.validationSummary();
		let summaryItems = summary.items;
		let list = document.createElement("dl");
		list.classList.add("dt-summary-overview");
		for(let i = 0; i < summaryItems.length; i++) {
			let dt = document.createElement("dt");
			dt.classList.add("dt-query");
			list.appendChild(dt);
			let dtTitle = document.createElement("span");
			dtTitle.textContent = summaryItems[i].title;
			dtTitle.classList.add("dt-query-title");
			dt.appendChild(dtTitle);
			let ddsubtitle = document.createElement("dd");
			ddsubtitle.textContent = summaryItems[i].subtitle;
			ddsubtitle.classList.add("dt-query-subtitle");
			dt.appendChild(ddsubtitle);
			let ddvalid = document.createElement("dd");
			ddvalid.textContent = "Valid: " + (summaryItems[i].allValid === true ? "Yes" : "No");
			ddvalid.classList.add("dt-query-valid");
			ddvalid.classList.add(summaryItems[i].allValid === true ? "valid" : "invalid");
			dt.appendChild(ddvalid);
			let ddtype = document.createElement("dd");
			ddtype.textContent = "Question type: " + summaryItems[i].type;
			ddtype.classList.add("dt-query-type");
			dt.appendChild(ddtype);
			
			for(let j = 0; j < summaryItems[i].queryValues.length; j++) {
				let inputList = document.createElement("dl");
				inputList.classList.add("dt-input");
				let dtinputName = document.createElement("dt");
				dtinputName.textContent = "Input label or name: " + summaryItems[i].queryValues[j].label;
				dtinputName.classList.add("dt-input-name");
				inputList.appendChild(dtinputName);
				if(summaryItems[i].queryValues[j].values.length > 0) {
					let ddinputValues = document.createElement("dd");
					ddinputValues.textContent = "Input values: " + summaryItems[i].queryValues[j].values.join(", ");
					ddinputValues.classList.add("dt-input-values");
					inputList.appendChild(ddinputValues);
				}
				let ddinputValid = document.createElement("dd");
				ddinputValid.textContent = "This input valid: " + (summaryItems[i].queryValues[j].valid === true ? "Yes" : "No");
				ddinputValid.classList.add("dt-input-valid");
				ddinputValid.classList.add(summaryItems[i].queryValues[j].valid === true ? "valid" : "invalid");
				inputList.appendChild(ddinputValid);
				dt.appendChild(inputList);
			}
		}
		
		/* Create retry a link button */
		let retry = document.createElement("a");
		retry.textContent = "Retry";
		retry.href = "#";
		retry.classList.add("dt-retry");
		retry.addEventListener("click", (event) => {
			event.preventDefault();
			location.reload();
		});
		list.appendChild(retry);
		
		/* Remove summary if already exists */
		if(this.summaryList) {
			this.summaryList.parentElement.removeChild(this.summaryList);
		}
		this.summaryList = list;
		
		/* Append new summary */
		this.container.appendChild(list);
		
		return this;
	}
}