/** * An extension of the native FormData implementation. * * This class functions the same way that the default FormData does, but it is more opinionated about how * input fields of certain types should be evaluated and handled. * * It also adds support for certain Foundry VTT specific concepts including: * Support for defined data types and type conversion * Support for TinyMCE editors * Support for editable HTML elements * * @extends {FormData} * * @param {HTMLFormElement} form The form being processed * @param {object} options Options which configure form processing * @param {Record} [options.editors] A record of TinyMCE editor metadata objects, indexed by their update key * @param {Record} [options.dtypes] A mapping of data types for form fields * @param {boolean} [options.disabled=false] Include disabled fields? * @param {boolean} [options.readonly=false] Include readonly fields? */ class FormDataExtended extends FormData { constructor(form, {dtypes={}, editors={}, disabled=false, readonly=true}={}) { super(); /** * A mapping of data types requested for each form field. * @type {{string, string}} */ this.dtypes = dtypes; /** * A record of TinyMCE editors which are linked to this form. * @type {Record} */ this.editors = editors; /** * The object representation of the form data, available once processed. * @type {object} */ Object.defineProperty(this, "object", {value: {}, writable: false, enumerable: false}); // Process the provided form this.process(form, {disabled, readonly}); } /* -------------------------------------------- */ /** * Process the HTML form element to populate the FormData instance. * @param {HTMLFormElement} form The HTML form being processed * @param {object} options Options forwarded from the constructor */ process(form, options) { this.#processFormFields(form, options); this.#processEditableHTML(form, options); this.#processEditors(); // Emit the formdata event for compatibility with the parent FormData class form.dispatchEvent(new FormDataEvent("formdata", {formData: this})); } /* -------------------------------------------- */ /** * Assign a value to the FormData instance which always contains JSON strings. * Also assign the cast value in its preferred data type to the parsed object representation of the form data. * @param {string} name The field name * @param {any} value The raw extracted value from the field * @inheritDoc */ set(name, value) { this.object[name] = value; if ( value instanceof Array ) value = JSON.stringify(value); super.set(name, value); } /* -------------------------------------------- */ /** * Append values to the form data, adding them to an array. * @param {string} name The field name to append to the form * @param {any} value The value to append to the form data * @inheritDoc */ append(name, value) { if ( name in this.object ) { if ( !Array.isArray(this.object[name]) ) this.object[name] = [this.object[name]]; } else this.object[name] = []; this.object[name].push(value); super.append(name, value); } /* -------------------------------------------- */ /** * Process all standard HTML form field elements from the form. * @param {HTMLFormElement} form The form being processed * @param {object} options Options forwarded from the constructor * @param {boolean} [options.disabled] Process disabled fields? * @param {boolean} [options.readonly] Process readonly fields? * @private */ #processFormFields(form, {disabled, readonly}={}) { if ( !disabled && form.hasAttribute("disabled") ) return; const mceEditorIds = Object.values(this.editors).map(e => e.mce?.id); for ( const element of form.elements ) { const name = element.name; // Skip fields which are unnamed or already handled if ( !name || this.has(name) ) continue; // Skip buttons and editors if ( (element.tagName === "BUTTON") || mceEditorIds.includes(name) ) continue; // Skip disabled or read-only fields if ( !disabled && (element.disabled || element.closest("fieldset")?.disabled) ) continue; if ( !readonly && element.readOnly ) continue; // Extract and process the value of the field const field = form.elements[name]; const value = this.#getFieldValue(name, field); this.set(name, value); } } /* -------------------------------------------- */ /** * Process editable HTML elements (ones with a [data-edit] attribute). * @param {HTMLFormElement} form The form being processed * @param {object} options Options forwarded from the constructor * @param {boolean} [options.disabled] Process disabled fields? * @param {boolean} [options.readonly] Process readonly fields? * @private */ #processEditableHTML(form, {disabled, readonly}={}) { const editableElements = form.querySelectorAll("[data-edit]"); for ( const element of editableElements ) { const name = element.dataset.edit; if ( this.has(name) || (name in this.editors) ) continue; if ( (!disabled && element.disabled) || (!readonly && element.readOnly) ) continue; let value; if (element.tagName === "IMG") value = element.getAttribute("src"); else value = element.innerHTML.trim(); this.set(name, value); } } /* -------------------------------------------- */ /** * Process TinyMCE editor instances which are present in the form. * @private */ #processEditors() { for ( const [name, editor] of Object.entries(this.editors) ) { if ( !editor.instance ) continue; if ( editor.options.engine === "tinymce" ) { const content = editor.instance.getContent(); this.delete(editor.mce.id); // Delete hidden MCE inputs this.set(name, content); } else if ( editor.options.engine === "prosemirror" ) { this.set(name, ProseMirror.dom.serializeString(editor.instance.view.state.doc.content)); } } } /* -------------------------------------------- */ /** * Obtain the parsed value of a field conditional on its element type and requested data type. * @param {string} name The field name being processed * @param {HTMLElement|RadioNodeList} field The HTML field or a RadioNodeList of multiple fields * @returns {*} The processed field value * @private */ #getFieldValue(name, field) { // Multiple elements with the same name if ( field instanceof RadioNodeList ) { const fields = Array.from(field); if ( fields.every(f => f.type === "radio") ) { const chosen = fields.find(f => f.checked); return chosen ? this.#getFieldValue(name, chosen) : undefined; } return Array.from(field).map(f => this.#getFieldValue(name, f)); } // Record requested data type const dataType = field.dataset.dtype || this.dtypes[name]; // Checkbox if ( field.type === "checkbox" ) { // Non-boolean checkboxes with an explicit value attribute yield that value or null if ( field.hasAttribute("value") && (dataType !== "Boolean") ) { return this.#castType(field.checked ? field.value : null, dataType); } // Otherwise, true or false based on the checkbox checked state return this.#castType(field.checked, dataType); } // Number and Range if ( ["number", "range"].includes(field.type) ) { if ( field.value === "" ) return null; else return this.#castType(field.value, dataType || "Number"); } // Multi-Select if ( field.type === "select-multiple" ) { return Array.from(field.options).reduce((chosen, opt) => { if ( opt.selected ) chosen.push(this.#castType(opt.value, dataType)); return chosen; }, []); } // Radio Select if ( field.type === "radio" ) { return field.checked ? this.#castType(field.value, dataType) : null; } // Other field types return this.#castType(field.value, dataType); } /* -------------------------------------------- */ /** * Cast a processed value to a desired data type. * @param {any} value The raw field value * @param {string} dataType The desired data type * @returns {any} The resulting data type * @private */ #castType(value, dataType) { if ( value instanceof Array ) return value.map(v => this.#castType(v, dataType)); if ( [undefined, null].includes(value) || (dataType === "String") ) return value; // Boolean if ( dataType === "Boolean" ) { if ( value === "false" ) return false; return Boolean(value); } // Number else if ( dataType === "Number" ) { if ( (value === "") || (value === "null") ) return null; return Number(value); } // Serialized JSON else if ( dataType === "JSON" ) { return JSON.parse(value); } // Other data types if ( window[dataType] instanceof Function ) { try { return window[dataType](value); } catch(err) { console.warn(`The form field value "${value}" was not able to be cast to the requested data type ${dataType}`); } } return value; } }