270 lines
9.3 KiB
JavaScript
270 lines
9.3 KiB
JavaScript
|
|
/**
|
||
|
|
* 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<string, object>} [options.editors] A record of TinyMCE editor metadata objects, indexed by their update key
|
||
|
|
* @param {Record<string, string>} [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<string, object>}
|
||
|
|
*/
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|