fune/browser/extensions/formautofill/content/manageDialog.js
Scott Wu 87c666e326 Bug 1367322 - Preferences search support for credit card autofill. r=evanxd,lchang
MozReview-Commit-ID: L8sSQkQUvq4

--HG--
extra : rebase_source : f0038cec0e286f5c57977217b892347582b602fe
2017-09-14 18:18:24 +08:00

425 lines
13 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* exported ManageAddresses, ManageCreditCards */
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml";
const EDIT_CREDIT_CARD_URL = "chrome://formautofill/content/editCreditCard.xhtml";
const AUTOFILL_BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties";
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://formautofill/FormAutofillUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "profileStorage",
"resource://formautofill/ProfileStorage.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "MasterPassword",
"resource://formautofill/MasterPassword.jsm");
this.log = null;
FormAutofillUtils.defineLazyLogGetter(this, "manageAddresses");
class ManageRecords {
constructor(subStorageName, elements) {
this._storageInitPromise = profileStorage.initialize();
this._subStorageName = subStorageName;
this._elements = elements;
this._newRequest = false;
this._isLoadingRecords = false;
this.prefWin = window.opener;
this.localizeDocument();
window.addEventListener("DOMContentLoaded", this, {once: true});
}
async init() {
await this.loadRecords();
this.attachEventListeners();
// For testing only: Notify when the dialog is ready for interaction
window.dispatchEvent(new CustomEvent("FormReady"));
}
uninit() {
log.debug("uninit");
this.detachEventListeners();
this._elements = null;
}
localizeDocument() {
FormAutofillUtils.localizeMarkup(AUTOFILL_BUNDLE_URI, document);
}
/**
* Get the selected options on the addresses element.
*
* @returns {array<DOMElement>}
*/
get _selectedOptions() {
return Array.from(this._elements.records.selectedOptions);
}
/**
* Get storage and ensure it has been initialized.
* @returns {object}
*/
async getStorage() {
await this._storageInitPromise;
return profileStorage[this._subStorageName];
}
/**
* Load records and render them. This function is a wrapper for _loadRecords
* to ensure any reentrant will be handled well.
*/
async loadRecords() {
// This function can be early returned when there is any reentrant happends.
// "_newRequest" needs to be set to ensure all changes will be applied.
if (this._isLoadingRecords) {
this._newRequest = true;
return;
}
this._isLoadingRecords = true;
await this._loadRecords();
// _loadRecords should be invoked again if there is any multiple entrant
// during running _loadRecords(). This step ensures that the latest request
// still is applied.
while (this._newRequest) {
this._newRequest = false;
await this._loadRecords();
}
this._isLoadingRecords = false;
// For testing only: Notify when records are loaded
this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded"));
}
async _loadRecords() {
let storage = await this.getStorage();
let records = storage.getAll();
// Sort by last modified time starting with most recent
records.sort((a, b) => b.timeLastModified - a.timeLastModified);
await this.renderRecordElements(records);
this.updateButtonsStates(this._selectedOptions.length);
}
/**
* Render the records onto the page while maintaining selected options if
* they still exist.
*
* @param {array<object>} records
*/
async renderRecordElements(records) {
let selectedGuids = this._selectedOptions.map(option => option.value);
this.clearRecordElements();
for (let record of records) {
let option = new Option(await this.getLabel(record),
record.guid,
false,
selectedGuids.includes(record.guid));
option.record = record;
this._elements.records.appendChild(option);
}
}
/**
* Remove all existing record elements.
*/
clearRecordElements() {
let parent = this._elements.records;
while (parent.lastChild) {
parent.removeChild(parent.lastChild);
}
}
/**
* Remove records by selected options.
*
* @param {array<DOMElement>} options
*/
async removeRecords(options) {
let storage = await this.getStorage();
// Pause listening to storage change event to avoid triggering `loadRecords`
// when removing records
Services.obs.removeObserver(this, "formautofill-storage-changed");
for (let option of options) {
storage.remove(option.value);
option.remove();
}
this.updateButtonsStates(this._selectedOptions);
// Resume listening to storage change event
Services.obs.addObserver(this, "formautofill-storage-changed");
// For testing only: notify record(s) has been removed
this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved"));
}
/**
* Enable/disable the Edit and Remove buttons based on number of selected
* options.
*
* @param {number} selectedCount
*/
updateButtonsStates(selectedCount) {
log.debug("updateButtonsStates:", selectedCount);
if (selectedCount == 0) {
this._elements.edit.setAttribute("disabled", "disabled");
this._elements.remove.setAttribute("disabled", "disabled");
} else if (selectedCount == 1) {
this._elements.edit.removeAttribute("disabled");
this._elements.remove.removeAttribute("disabled");
} else if (selectedCount > 1) {
this._elements.edit.setAttribute("disabled", "disabled");
this._elements.remove.removeAttribute("disabled");
}
}
/**
* Handle events
*
* @param {DOMEvent} event
*/
handleEvent(event) {
switch (event.type) {
case "DOMContentLoaded": {
this.init();
break;
}
case "click": {
this.handleClick(event);
break;
}
case "change": {
this.updateButtonsStates(this._selectedOptions.length);
break;
}
case "unload": {
this.uninit();
break;
}
case "keypress": {
this.handleKeyPress(event);
break;
}
}
}
/**
* Handle click events
*
* @param {DOMEvent} event
*/
handleClick(event) {
if (event.target == this._elements.remove) {
this.removeRecords(this._selectedOptions);
} else if (event.target == this._elements.add) {
this.openEditDialog();
} else if (event.target == this._elements.edit ||
event.target.parentNode == this._elements.records && event.detail > 1) {
this.openEditDialog(this._selectedOptions[0].record);
}
}
/**
* Handle key press events
*
* @param {DOMEvent} event
*/
handleKeyPress(event) {
if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
window.close();
}
}
observe(subject, topic, data) {
switch (topic) {
case "formautofill-storage-changed": {
this.loadRecords();
}
}
}
/**
* Attach event listener
*/
attachEventListeners() {
window.addEventListener("unload", this, {once: true});
window.addEventListener("keypress", this);
this._elements.records.addEventListener("change", this);
this._elements.records.addEventListener("click", this);
this._elements.controlsContainer.addEventListener("click", this);
Services.obs.addObserver(this, "formautofill-storage-changed");
}
/**
* Remove event listener
*/
detachEventListeners() {
window.removeEventListener("keypress", this);
this._elements.records.removeEventListener("change", this);
this._elements.records.removeEventListener("click", this);
this._elements.controlsContainer.removeEventListener("click", this);
Services.obs.removeObserver(this, "formautofill-storage-changed");
}
}
class ManageAddresses extends ManageRecords {
constructor(elements) {
super("addresses", elements);
elements.add.setAttribute("searchkeywords", FormAutofillUtils.EDIT_ADDRESS_KEYWORDS
.map(key => FormAutofillUtils.stringBundle.GetStringFromName(key))
.join("\n"));
}
/**
* Open the edit address dialog to create/edit an address.
*
* @param {object} address [optional]
*/
openEditDialog(address) {
this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, null, address);
}
/**
* Get address display label. It should display up to two pieces of
* information, separated by a comma.
*
* @param {object} address
* @returns {string}
*/
getLabel(address) {
// TODO: Implement a smarter way for deciding what to display
// as option text. Possibly improve the algorithm in
// ProfileAutoCompleteResult.jsm and reuse it here.
const fieldOrder = [
"name",
"-moz-street-address-one-line", // Street address
"address-level2", // City/Town
"organization", // Company or organization name
"address-level1", // Province/State (Standardized code if possible)
"country-name", // Country name
"postal-code", // Postal code
"tel", // Phone number
"email", // Email address
];
let parts = [];
if (address["street-address"]) {
address["-moz-street-address-one-line"] = FormAutofillUtils.toOneLineAddress(
address["street-address"]
);
}
for (const fieldName of fieldOrder) {
let string = address[fieldName];
if (string) {
parts.push(string);
}
if (parts.length == 2) {
break;
}
}
return parts.join(", ");
}
}
class ManageCreditCards extends ManageRecords {
constructor(elements) {
super("creditCards", elements);
elements.add.setAttribute("searchkeywords", FormAutofillUtils.EDIT_CREDITCARD_KEYWORDS
.map(key => FormAutofillUtils.stringBundle.GetStringFromName(key))
.join("\n"));
this._hasMasterPassword = MasterPassword.isEnabled;
this._isDecrypted = false;
if (this._hasMasterPassword) {
elements.showHideCreditCards.setAttribute("hidden", true);
}
}
/**
* Open the edit address dialog to create/edit a credit card.
*
* @param {object} creditCard [optional]
*/
async openEditDialog(creditCard) {
// If master password is set, ask for password if user is trying to edit an
// existing credit card.
if (!this._hasMasterPassword || !creditCard || await MasterPassword.prompt()) {
this.prefWin.gSubDialog.open(EDIT_CREDIT_CARD_URL, null, creditCard);
}
}
/**
* Get credit card display label. It should display masked numbers and the
* cardholder's name, separated by a comma. If `showCreditCards` is set to
* true, decrypted credit card numbers are shown instead.
*
* @param {object} creditCard
* @param {boolean} showCreditCards [optional]
* @returns {string}
*/
async getLabel(creditCard, showCreditCards = false) {
let parts = [];
if (creditCard["cc-number"]) {
let ccLabel;
if (showCreditCards) {
ccLabel = await MasterPassword.decrypt(creditCard["cc-number-encrypted"]);
} else {
let {affix, label} = FormAutofillUtils.fmtMaskedCreditCardLabel(creditCard["cc-number"]);
ccLabel = `${affix} ${label}`;
}
parts.push(ccLabel);
}
if (creditCard["cc-name"]) {
parts.push(creditCard["cc-name"]);
}
return parts.join(", ");
}
async toggleShowHideCards(options) {
this._isDecrypted = !this._isDecrypted;
this.updateShowHideButtonState();
await this.updateLabels(options, this._isDecrypted);
}
async updateLabels(options, isDecrypted) {
for (let option of options) {
option.text = await this.getLabel(option.record, isDecrypted);
}
// For testing only: Notify when credit cards labels have been updated
this._elements.records.dispatchEvent(new CustomEvent("LabelsUpdated"));
}
async renderRecordElements(records) {
// Revert back to encrypted form when re-rendering happens
this._isDecrypted = false;
await super.renderRecordElements(records);
}
updateButtonsStates(selectedCount) {
this.updateShowHideButtonState();
super.updateButtonsStates(selectedCount);
}
updateShowHideButtonState() {
if (this._elements.records.length) {
this._elements.showHideCreditCards.removeAttribute("disabled");
} else {
this._elements.showHideCreditCards.setAttribute("disabled", true);
}
this._elements.showHideCreditCards.textContent =
this._isDecrypted ? FormAutofillUtils.stringBundle.GetStringFromName("hideCreditCards") :
FormAutofillUtils.stringBundle.GetStringFromName("showCreditCards");
}
handleClick(event) {
if (event.target == this._elements.showHideCreditCards) {
this.toggleShowHideCards(this._elements.records.options);
}
super.handleClick(event);
}
}