chromium/chrome/browser/resources/settings/autofill_page/address_edit_dialog.ts

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview 'address-edit-dialog' is the dialog that allows editing a saved
 * address.
 */
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/cr_elements/cr_textarea/cr_textarea.js';
import 'chrome://resources/cr_elements/md_select.css.js';
import '../settings_shared.css.js';
import '../settings_vars.css.js';

import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {flush, microTask, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './address_edit_dialog.html.js';
import * as uiComponents from './address_edit_dialog_components.js';

export interface SettingsAddressEditDialogElement {
  $: {
    accountSourceNotice: HTMLElement,
    cancelButton: CrButtonElement,
    country: HTMLSelectElement,
    dialog: CrDialogElement,
    saveButton: CrButtonElement,
  };
}

type CountryEntry = chrome.autofillPrivate.CountryEntry;
type AddressEntry = chrome.autofillPrivate.AddressEntry;
type AccountInfo = chrome.autofillPrivate.AccountInfo;
type AddressComponent = chrome.autofillPrivate.AddressComponent;
type AddressComponentRow = chrome.autofillPrivate.AddressComponentRow;
type AddressComponents = chrome.autofillPrivate.AddressComponents;
const AddressRecordType = chrome.autofillPrivate.AddressRecordType;
const FieldType = chrome.autofillPrivate.FieldType;
const SettingsAddressEditDialogElementBase = I18nMixin(PolymerElement);

export class SettingsAddressEditDialogElement extends
    SettingsAddressEditDialogElementBase {
  static get is() {
    return 'settings-address-edit-dialog';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      address: Object,
      accountInfo: Object,

      title_: String,
      validationError_: String,

      countries_: Array,

      /**
       * Updates the address wrapper.
       */
      countryCode_: {
        type: String,
        observer: 'onCountryCodeChanged_',
      },

      components_: Array,
      phoneNumber_: String,
      email_: String,
      canSave_: Boolean,

      isAccountAddress_: {
        type: Boolean,
        computed: 'isAddressStoredInAccount_(address, accountInfo)',
        value: false,
      },

      accountAddressSourceNotice_: {
        type: String,
        computed: 'getAccountAddressSourceNotice_(address, accountInfo)',
      },
    };
  }

  address: AddressEntry;
  accountInfo?: AccountInfo;

  /**
   * Original address is a snapshot of the address made at initialization,
   * it is a referce for soft (or "dont make it worse") validation, which
   * basically means skipping validation for fields that are already invalid.
   */
  private title_: string;
  private validationError_?: string;
  private countries_: CountryEntry[];
  private addressFields_:
      Map<chrome.autofillPrivate.FieldType, string|undefined> = new Map();
  private originalAddressFields_?:
      Map<chrome.autofillPrivate.FieldType, string|undefined>;
  private countryCode_: string|undefined;
  private components_: uiComponents.AddressComponentUi[][] = [];
  private canSave_: boolean;
  private isAccountAddress_: boolean;
  private countryInfo_: CountryDetailManager =
      CountryDetailManagerImpl.getInstance();

  override connectedCallback(): void {
    super.connectedCallback();

    assert(this.address);
    for (const entry of this.address.fields) {
      this.addressFields_.set(entry.type, entry.value);
    }

    const forAccountAddressProfile = !!this.address.guid &&
        this.address.metadata !== undefined &&
        this.address.metadata.recordType === AddressRecordType.ACCOUNT;
    this.countryInfo_.getCountryList(forAccountAddressProfile)
        .then(countryList => {
          this.countries_ = countryList;

          const isEditingExistingAddress = !!this.address.guid;
          this.title_ = this.i18n(
              isEditingExistingAddress ? 'editAddressTitle' :
                                         'addAddressTitle');
          this.originalAddressFields_ = isEditingExistingAddress ?
              new Map(this.addressFields_) :
              undefined;

          microTask.run(() => {
            const countryField =
                this.addressFields_.get(FieldType.ADDRESS_HOME_COUNTRY);
            if (!countryField) {
              assert(countryList.length > 0);
              // If the address is completely empty, the dialog is creating a
              // new address. The first address in the country list is what we
              // suspect the user's country is.
              this.addressFields_.set(
                  FieldType.ADDRESS_HOME_COUNTRY, countryList[0].countryCode);
            }
            this.countryCode_ =
                this.addressFields_.get(FieldType.ADDRESS_HOME_COUNTRY);
          });
        });

    // Open is called on the dialog after the address wrapper has been
    // updated.
  }

  private fire_(eventName: string, detail?: any): void {
    this.dispatchEvent(
        new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
  }

  /**
   * Updates the wrapper that represents this address in the country's format.
   */
  private updateAddressComponents_(): void {
    // Default to the last country used if no country code is provided.
    const countryCode = this.countryCode_ || this.countries_[0].countryCode;
    this.countryInfo_.getAddressFormat(countryCode as string).then(format => {
      this.address.languageCode = format.languageCode;
      // TODO(crbug.com/40253382): validation is performed for addresses from
      // the user account only now, this flag should be removed when it
      // becomes the only type of addresses
      const skipValidation = !this.isAccountAddress_;

      this.components_ = format.components.map(
          (componentRow: AddressComponentRow, rowIndex: number) => {
            return componentRow.row.map(
                (component: AddressComponent, colIndex: number) =>
                    new uiComponents.AddressComponentUi(
                        this.addressFields_, this.originalAddressFields_,
                        component.field, component.fieldName,
                        this.notifyComponentValidity_.bind(
                            this, rowIndex, colIndex),
                        component.isLongField ? 'long' : '',
                        component.field ===
                            FieldType.ADDRESS_HOME_STREET_ADDRESS,
                        skipValidation, component.isRequired));
          });

      // Phone and email do not come in the address format as fields, but
      // should be editable and saveable in the resulting address.
      const contactsRowIndex = this.components_.length;
      this.components_.push([
        new uiComponents.AddressComponentUi(
            this.addressFields_, this.originalAddressFields_,
            FieldType.PHONE_HOME_WHOLE_NUMBER, this.i18n('addressPhone'),
            this.notifyComponentValidity_.bind(this, contactsRowIndex, 0),
            'last-row'),
        new uiComponents.AddressComponentUi(
            this.addressFields_, this.originalAddressFields_,
            FieldType.EMAIL_ADDRESS, this.i18n('addressEmail'),
            this.notifyComponentValidity_.bind(this, contactsRowIndex, 1),
            'long last-row'),
      ]);

      // Flush dom before resize and savability updates.
      flush();

      this.updateCanSave_();

      this.fire_('on-update-address-wrapper');  // For easier testing.

      if (!this.$.dialog.open) {
        this.$.dialog.showModal();
      }
    });
  }

  /**
   * Determines whether component with specified validation property
   * should be rendered as invalid in the template.
   */
  private isVisuallyInvalid_(isValidatable: boolean, isValid: boolean):
      boolean {
    return isValidatable && !isValid;
  }

  /**
   * Makes component's potentially invalid state visible, it makes
   * the component validatable and notifies the template engine.
   * The component is addressed by row/col to leverage Polymer's notifications.
   */
  private notifyComponentValidity_(row: number, col: number): void {
    this.components_[row][col].makeValidatable();

    const componentReference = `components_.${row}.${col}`;
    this.notifyPath(componentReference + '.isValidatable');
    this.notifyPath(componentReference + '.isValid');

    this.updateCanSave_();
  }

  /**
   * Notifies all components validity (see notifyComponentValidity_()).
   */
  private notifyValidity_(): void {
    this.components_.forEach((row, i) => {
      row.forEach((_col, j) => this.notifyComponentValidity_(i, j));
    });
  }

  private updateCanSave_(): void {
    this.validationError_ = '';

    if ((!this.countryCode_ && this.hasAnyValue_()) ||
        (this.countryCode_ &&
         (!this.hasInvalidComponent_() ||
          this.hasUncoveredInvalidComponent_()))) {
      this.canSave_ = true;
      this.fire_('on-update-can-save');  // For easier testing.
      return;
    }

    if (this.isAccountAddress_) {
      const nInvalid = this.countInvalidComponent_();
      if (nInvalid === 1) {
        this.validationError_ = this.i18n('editAddressRequiredFieldError');
      } else if (nInvalid > 1) {
        this.validationError_ = this.i18n('editAddressRequiredFieldsError');
      }
    }

    this.canSave_ = false;
    this.fire_('on-update-can-save');  // For easier testing.
  }

  private getCode_(country: CountryEntry): string {
    return country.countryCode || 'SPACER';
  }

  private getName_(country: CountryEntry): string {
    return country.name || '------';
  }

  private isDivision_(country: CountryEntry): boolean {
    return !country.countryCode;
  }

  private isAddressStoredInAccount_(): boolean {
    if (this.address.guid) {
      return this.address.metadata !== undefined &&
          this.address.metadata.recordType === AddressRecordType.ACCOUNT;
    }

    return !!this.accountInfo?.isEligibleForAddressAccountStorage;
  }

  private getAccountAddressSourceNotice_(): string|undefined {
    if (this.accountInfo) {
      return this.i18n(
          this.address.guid ? 'editAccountAddressSourceNotice' :
                              'newAccountAddressSourceNotice',
          this.accountInfo.email);
    }

    return undefined;
  }

  /**
   * Tells whether at least one address component (except country)
   * has a non empty value.
   */
  private hasAnyValue_(): boolean {
    return this.components_.flat().some(component => component.hasValue);
  }

  /**
   * Tells whether at least one address component (except country) is not valid.
   */
  private hasInvalidComponent_(): boolean {
    return this.countInvalidComponent_() > 0;
  }

  /**
   * Counts how many invalid address componets (except country) are in the form.
   */
  private countInvalidComponent_(): number {
    return this.components_.flat()
        .filter(component => !component.isValid)
        .length;
  }

  /**
   * Tells whether at least one address component (except country)
   * is not valid and is not validatable also, i.e. its invalid state is
   * not visible to the user.
   */
  private hasUncoveredInvalidComponent_(): boolean {
    return this.components_.flat().some(
        component => !component.isValid && !component.isValidatable);
  }

  private onCancelClick_(): void {
    chrome.metricsPrivate.recordBoolean(
        'Autofill.Settings.EditAddress',
        /*confirmed=*/ false);
    this.$.dialog.cancel();
  }

  /**
   * Handler for tapping the save button.
   */
  private onSaveButtonClick_(): void {
    this.notifyValidity_();

    this.updateCanSave_();
    if (!this.canSave_) {
      return;
    }

    this.address.fields = [];
    this.addressFields_.forEach((value, key, _map) => {
      this.address.fields.push({type: key, value: value});
    });

    chrome.metricsPrivate.recordBoolean(
        'Autofill.Settings.EditAddress',
        /*confirmed=*/ true);
    this.fire_('save-address', this.address);
    this.$.dialog.close();
  }

  private onCountryCodeChanged_(): void {
    this.updateAddressComponents_();
  }

  /**
   * Syncs the country code back to the address and rebuilds the address
   * components for the new location.
   */
  private onCountryCodeSelectChange_(): void {
    this.addressFields_.set(
        FieldType.ADDRESS_HOME_COUNTRY, this.$.country.value);
    this.countryCode_ = this.$.country.value;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-address-edit-dialog': SettingsAddressEditDialogElement;
  }
}

customElements.define(
    SettingsAddressEditDialogElement.is, SettingsAddressEditDialogElement);

export interface CountryDetailManager {
  /**
   * Gets the list of available countries.
   * The default country will be first, followed by a separator, followed by
   * an alphabetized list of countries available.
   */
  getCountryList(forAccountAddressProfile: boolean): Promise<CountryEntry[]>;

  /**
   * Gets the address format for a given country code.
   */
  getAddressFormat(countryCode: string): Promise<AddressComponents>;
}

/**
 * Default implementation. Override for testing.
 */
export class CountryDetailManagerImpl implements CountryDetailManager {
  getCountryList(forAccountAddressProfile: boolean): Promise<CountryEntry[]> {
    return chrome.autofillPrivate.getCountryList(forAccountAddressProfile);
  }

  getAddressFormat(countryCode: string): Promise<AddressComponents> {
    return chrome.autofillPrivate.getAddressComponents(countryCode);
  }

  static getInstance(): CountryDetailManager {
    return instance || (instance = new CountryDetailManagerImpl());
  }

  static setInstance(obj: CountryDetailManager): void {
    instance = obj;
  }
}

let instance: CountryDetailManager|null = null;