addressFormLayout.mjs (6296B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 ChromeUtils.defineESModuleGetters(lazy, { 7 FormAutofill: "resource://autofill/FormAutofill.sys.mjs", 8 FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", 9 }); 10 11 // Defines template descriptors for generating elements in convertLayoutToUI. 12 const fieldTemplates = { 13 commonAttributes(item) { 14 return { 15 id: item.fieldId, 16 name: item.fieldId, 17 required: item.required, 18 value: item.value ?? "", 19 // Conditionally add pattern attribute since pattern=""/false/undefined 20 // results in weird behaviour. 21 ...(item.pattern && { pattern: item.pattern }), 22 }; 23 }, 24 input(item) { 25 return { 26 tag: "input", 27 type: item.type ?? "text", 28 ...this.commonAttributes(item), 29 }; 30 }, 31 textarea(item) { 32 return { 33 tag: "textarea", 34 ...this.commonAttributes(item), 35 }; 36 }, 37 select(item) { 38 return { 39 tag: "select", 40 children: item.options.map(({ value, text }) => ({ 41 tag: "option", 42 selected: value === item.value, 43 value, 44 text, 45 })), 46 ...this.commonAttributes(item), 47 }; 48 }, 49 }; 50 51 /** 52 * Creates an HTML element with specified attributes and children. 53 * 54 * @param {string} tag - Tag name for the element to create. 55 * @param {object} options - Options object containing attributes and children. 56 * @param {object} options.attributes - Element's Attributes/Props (id, class, etc.) 57 * @param {Array} options.children - Element's children (array of objects with tag and options). 58 * @returns {HTMLElement} The newly created element. 59 */ 60 const createElement = (tag, { children = [], ...attributes }) => { 61 const element = document.createElement(tag); 62 63 for (let [attributeName, attributeValue] of Object.entries(attributes)) { 64 if (attributeName in element) { 65 element[attributeName] = attributeValue; 66 } else { 67 element.setAttribute(attributeName, attributeValue); 68 } 69 } 70 71 for (let { tag: childTag, ...childRest } of children) { 72 element.appendChild(createElement(childTag, childRest)); 73 } 74 75 return element; 76 }; 77 78 /** 79 * Generator that creates UI elements from `fields` object, using localization from `l10nStrings`. 80 * 81 * @param {Array} fields - Array of objects as returned from `FormAutofillUtils.getFormLayout`. 82 * @param {object} l10nStrings - Key-value pairs for field label localization. 83 * @yields {HTMLElement} - A localized label element with constructed from a field. 84 */ 85 function* convertLayoutToUI(fields, l10nStrings) { 86 for (const item of fields) { 87 // eslint-disable-next-line no-nested-ternary 88 const fieldTag = item.options 89 ? "select" 90 : item.multiline 91 ? "textarea" 92 : "input"; 93 94 const fieldUI = { 95 label: { 96 id: `${item.fieldId}-container`, 97 class: `container ${item.newLine ? "new-line" : ""}`, 98 }, 99 field: fieldTemplates[fieldTag](item), 100 span: { 101 class: "label-text", 102 textContent: l10nStrings[item.l10nId] ?? "", 103 }, 104 }; 105 106 const label = createElement("label", fieldUI.label); 107 const { tag, ...rest } = fieldUI.field; 108 const span = createElement("span", fieldUI.span); 109 label.appendChild(span); 110 const field = createElement(tag, rest); 111 label.appendChild(field); 112 yield label; 113 } 114 } 115 116 /** 117 * Retrieves the current form data from the current form element on the page. 118 * NOTE: We are intentionally not using FormData here because on iOS we have states where 119 * selects are disabled and FormData ignores disabled elements. We want getCurrentFormData 120 * to always refelect the exact state of the form. 121 * 122 * @returns {object} An object containing key-value pairs of form data. 123 */ 124 export const getCurrentFormData = () => { 125 const formData = {}; 126 for (const element of document.querySelector("form").elements) { 127 formData[element.name] = element.value ?? ""; 128 } 129 return formData; 130 }; 131 132 /** 133 * Checks if the form can be submitted based on the number of non-empty values. 134 * TODO(Bug 1891734): Add address validation. Right now we don't do any validation. (2 fields mimics the old behaviour ). 135 * 136 * @returns {boolean} True if the form can be submitted 137 */ 138 export const canSubmitForm = () => { 139 const formData = getCurrentFormData(); 140 const validValues = Object.values(formData).filter(Boolean); 141 return validValues.length >= 2; 142 }; 143 144 /** 145 * Generates a form layout based on record data and localization strings. 146 * 147 * @param {HTMLFormElement} formElement - Target form element. 148 * @param {object} record - Address record, includes at least country code defaulted to FormAutofill.DEFAULT_REGION. 149 * @param {object} l10nStrings - Localization strings map. 150 */ 151 export const createFormLayoutFromRecord = ( 152 formElement, 153 record = { country: lazy.FormAutofill.DEFAULT_REGION }, 154 l10nStrings = {} 155 ) => { 156 // Always clear select values because they are not persisted between countries. 157 // For example from US with state NY, we don't want the address-level1 to be NY 158 // when changing to another country that doesn't have state options 159 const selects = formElement.querySelectorAll("select:not(#country)"); 160 for (const select of selects) { 161 select.value = ""; 162 } 163 164 // Get old data to persist before clearing form 165 const formData = getCurrentFormData(); 166 record = { 167 ...record, 168 ...formData, 169 }; 170 171 formElement.innerHTML = ""; 172 const fields = lazy.FormAutofillUtils.getFormLayout(record); 173 174 const layoutGenerator = convertLayoutToUI(fields, l10nStrings); 175 176 for (const fieldElement of layoutGenerator) { 177 formElement.appendChild(fieldElement); 178 } 179 180 document.querySelector("#country").addEventListener( 181 "change", 182 ev => 183 // Allow some time for the user to type 184 // before we set the new country and re-render 185 setTimeout(() => { 186 record.country = ev.target.value; 187 createFormLayoutFromRecord(formElement, record, l10nStrings); 188 }, 300), 189 { once: true } 190 ); 191 192 // Used to notify tests that the form has been updated and is ready 193 window.dispatchEvent(new CustomEvent("FormReadyForTests")); 194 };