ui/forms/form.js
const { Context } = require('../../core/context')
const { Field } = require('./field');
const { Util } = require('../../core/util');
/**
* The Form class converts objects into a structure that can be
* run through a template and converted into an HTML form.
*
*/
class Form {
/**
* This returns a new form object that DART's templates can render
* as an HTML form.
*
* @param {string} objType - The name of the class this form will
* represent. For example, AppSetting, RemoteRepository, etc.
* These are generally subclasses of PersistentObject.
*
* @param {PersistentObject|object} obj - The object from which you want
* to create a form. This class will generate the form fields for all
* of the attributes of obj, except those specified in the exclude param.
* The obj param should be an instance of an object derived from
* {@ref PersistentObject}, but you can create an empty form by passing
* in an empty object like so:
*
* <code>
* var myForm = new Form('Object', {});
* </code>.
*
* @param {Array<string>} exclude - A list of object attributes to
* exclude from the form. The form will not include fields for these
* items. You don't need to include the 'id' or 'userCanDelete'
* attributes in this list.
* @default ['errors', 'help', 'required']
*
*/
constructor(objType, obj, exclude = ['errors', 'help', 'required']) {
this.objType = objType;
this.formId = Util.lcFirst(objType) + 'Form';
this.obj = obj;
if (typeof obj.errors === 'undefined') {
obj.errors = {};
}
this.exclude = exclude;
this.fields = {};
this.inlineForms = [];
this.changed = {};
this._initFields();
}
/**
* This creates fields for each attribute of form.obj, including
* setting the name, id, value, label, error message and help text
* of each item.
*
* The label and help text come from the locale file for each
* specific language. To set the label, add an entry to to the
* locale file named <ClassName>_<property>_label. To set the
* help text, add an entry to the locale file named
* <ClassName>_<property>_help. For example:
*
* <code>
* {
* "MyClass_surname_label": "Surname",
* "MyClass_surname_help": "Enter your family name.",
* }
* </code>
*
* This does not set the choices for select lists or checkbox groups.
* You'll have to do that yourself after the form is created.
*
* This method does not determine which HTML control will render
* on the form. Since that is a layout issue, you can specify that
* in your form template.
*
*/
_initFields() {
for(let [name, value] of Object.entries(this.obj)) {
if (!this.exclude.includes(name)) {
this._initField(name, value);
}
}
}
/**
* This creates a single field with the given name and value and
* adds it to the fields property of the form.
*
* @param {string} name - The name of the field to add.
*
* @value {string|number|boolean} value - The value of the field.
*/
_initField(name, value) {
let elementId = `${this.formId}_${name}`;
let label = this._getLocalizedLabel(name);
let field = new Field(elementId, name, label, value);
field.error = this.obj.errors[name];
this._setFieldHelpText(field);
this._setRequired(field);
this.fields[name] = field;
}
/**
* This method returns the localized label for a form field.
* It extracts the label from the locale file that matches the
* user's current system settings. The locale file should have
* an entry like the one below to specify the label.
*
* <code>
* {
* "MyClass_surname_label": "Surname",
* }
* </code>
*
* If there's no matching entry in the locale file, this returns
* a modified version of the property name with the first letter
* of each word capitalized. For example, fieldName "phoneNumber"
* would return "Phone Number" if there is no matching locale entry.
*
* @param {string} fieldName - The name of the field. This is the
* same as the name of the object property that the field will
* represent.
*
* @returns {string}
* @private
*/
_getLocalizedLabel(fieldName) {
let objType = this.obj.constructor.name;
let labelKey = `${objType}_${fieldName}_label`;
let labelText = Context.y18n.__(labelKey);
if (labelText == labelKey) {
labelText = Util.camelToTitle(fieldName);
}
return labelText;
}
/**
* This sets the html attribute "required='true'" on the field
* if the field is required. (That is, if it's included in the
* PersistentObject's required attribute.)
*
* @param {Field} field
* @private
*/
_setRequired(field) {
if (this.obj.required && Array.isArray(this.obj.required) && this.obj.required.includes(field.name)) {
field.attrs['required'] = true;
}
}
/**
* This sets the help tooltip for the field, if it can be found
* in the locale file. If Form.obj happens to be an AppSetting,
* it reads the help directly from the AppSetting.help property.
* Help strings in the locale file look like this:
*
* <code>
* {
* "MyClass_surname_help": "Enter your family name.",
* }
* </code>
*
* @param {Field} field
* @private
*/
_setFieldHelpText(field) {
if (this.obj.constructor.name == 'AppSetting' && this.obj.help) {
field.help = this.obj.help;
} else {
let helpKey = `${this.obj.constructor.name}_${field.name}_help`;
let helpText = Context.y18n.__(helpKey);
if (helpText && helpText != helpKey) {
field.help = helpText;
}
}
}
/**
* Returns true if the form contains errors. Check this after
* calling {@link parseFromDOM} to see if there were any validation
* errors.
*
* @returns {boolean}
*/
hasErrors() {
return this.obj.errors && Object.keys(this.obj.errors).length > 0;
}
/**
* This updates all of the values of Form.obj based on what the
* user entered in the HTML form. Note that because there are no
* PUT or POST operations in DART, this method reads directly from
* the HTML form on the current page, casting number and boolean
* values to the correct types.
*
* This also sets the values of the Form.changed object, which
* shows the old and new values of each property that the user
* changed.
*
* This returns nothing. Check the values of Form.obj and Form.changed
* after calling this. The controller classes call this method when
* users submit forms.
*/
parseFromDOM() {
// This is required for jest tests.
if ($ === undefined) {
var $ = require('jquery');
}
this.changed = {};
// TODO: Parse checkbox groups. See JobUploadForm.copyFormValuesToJob()
for (let [name, field] of Object.entries(this.fields)) {
let oldValue = this.obj[name];
let formValue = $(`#${field.id}`).val();
if (typeof formValue === "undefined") {
// Try parsing as checkbox group.
formValue = $(`input[name='${name}']:checked`).toArray().map(cb => cb.value);
}
try {
if (Array.isArray(formValue)) {
formValue = formValue.map(val => val.trim());
} else {
formValue = formValue.trim();
}
} catch (ex) {
// TODO: Unit tests log errors here.
// All null values come from select list.
// Can't figure out why. The following will
// show the HTML:
// console.log(document.body.innerHTML);
console.error(`Error processing form field '${name}': ${ex.toString()}`);
}
let newValue = this.castNewValueToType(oldValue, formValue);
if (oldValue !== newValue) {
this.changed[name] = {
old: oldValue,
new: newValue
};
this.obj[name] = newValue;
}
}
}
/**
* This casts a string value from a form to the correct type required
* by the underlying object, including strings, numbers and booleans.
*
* @param {string|number|boolean} oldValue - The original value, read
* from the property of Form.obj.
*
* @param {string} formValue - The value read from the HTML form, which
* will always be a string.
*
* @returns {string|number|boolean} - The formValue cast to a type
* that matches the type of oldValue.
*/
castNewValueToType(oldValue, formValue) {
let toType = typeof oldValue;
return Util.cast(formValue, toType);
}
/**
* This sets the errors on the form, based on errors flagged in the
* {@ref PersistentObject} Form.obj.errors.
*/
setErrors() {
this.changed = {};
this._initFields();
// Some heavily customized forms call a custom _init
// function after initializing fields.
if (typeof this._init === 'function') {
this._init();
}
}
}
module.exports.Form = Form;