chromium/chrome/browser/resources/history/product_specifications_lists.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './product_specifications_item.js';
import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import './shared_style.css.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';

import type {BrowserProxy} from '//resources/cr_components/commerce/browser_proxy.js';
import {BrowserProxyImpl} from '//resources/cr_components/commerce/browser_proxy.js';
import type {DomRepeat} from '//resources/polymer/v3_0/polymer/lib/elements/dom-repeat.js';
import type {PageCallbackRouter, ProductSpecificationsFeatureState, ProductSpecificationsSet} from 'chrome://resources/cr_components/commerce/shopping_service.mojom-webui.js';
import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import type {CrLazyRenderElement} from 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import {assert} from 'chrome://resources/js/assert.js';
import {FocusGrid} from 'chrome://resources/js/focus_grid.js';
import {OpenWindowProxyImpl} from 'chrome://resources/js/open_window_proxy.js';
import type {Uuid} from 'chrome://resources/mojo/mojo/public/mojom/base/uuid.mojom-webui.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {ItemCheckboxSelectEvent, ItemMenuOpenEvent, ProductSpecificationsItemElement} from './product_specifications_item.js';
import {getTemplate} from './product_specifications_lists.html.js';

export interface ProductSpecificationsListsElement {
  $: {
    'sharedMenu': CrLazyRenderElement<CrActionMenuElement>,
    'deleteItemDialog': CrLazyRenderElement<CrDialogElement>,
    'displayedItemsList': DomRepeat,
  };
}

declare global {
  interface HTMLElementEventMap {
    'product-spec-item-select': ItemCheckboxSelectEvent;
    'item-menu-open': ItemMenuOpenEvent;
  }
}
export class ProductSpecificationsListsElement extends PolymerElement {
  static get is() {
    return 'product-specifications-lists';
  }

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

  static get properties() {
    return {
      selectedItems: Object,
      searchTerm: String,
      pendingDelete_: {
        notify: true,
        type: Boolean,
      },
      lastSelectedIndex_: Number,
      allItems_: Array,
      displayedItems_: {
        type: Array,
        computed: 'computeDisplayedItems_(allItems_.*, searchTerm)',
      },
      uuidOfOpenMenu_: Object,
      productSpecificationsFeatureState_: Object,
    };
  }

  selectedItems: Set<string> = new Set();
  searchTerm: string = '';
  private pendingDelete_: boolean = false;
  private lastSelectedIndex_: number|undefined = undefined;
  private shoppingApi_: BrowserProxy = BrowserProxyImpl.getInstance();
  private allItems_: ProductSpecificationsSet[] = [];
  private displayedItems_: ProductSpecificationsSet[] = [];

  private focusGrid_: FocusGrid|null = null;
  private uuidOfOpenMenu_: Uuid|null = null;
  private callbackRouter_: PageCallbackRouter;
  private listenerIds_: number[] = [];
  private productSpecificationsFeatureState_: ProductSpecificationsFeatureState|
      null = null;

  constructor() {
    super();
    this.callbackRouter_ = this.shoppingApi_.getCallbackRouter();
  }

  override async connectedCallback() {
    super.connectedCallback();
    this.focusGrid_ = new FocusGrid();

    this.listenerIds_.push(
        this.callbackRouter_.onProductSpecificationsSetAdded.addListener(
            (set: ProductSpecificationsSet) => this.onSetAdded_(set)),
        this.callbackRouter_.onProductSpecificationsSetUpdated.addListener(
            (set: ProductSpecificationsSet) => this.onSetUpdated_(set)),
        this.callbackRouter_.onProductSpecificationsSetRemoved.addListener(
            (uuid: Uuid) => this.onSetRemoved_(uuid)),
    );

    // TODO(358131415): use listeners to update. Temporary workaround uses
    // window focus to update the feature state, to check signin.
    window.addEventListener('focus', async () => {
      const {state} =
          await this.shoppingApi_.getProductSpecificationsFeatureState();
      if (!state) {
        return;
      }
      this.productSpecificationsFeatureState_ = state;
    });

    const {state} =
        await this.shoppingApi_.getProductSpecificationsFeatureState();
    if (!state) {
      return;
    }
    this.productSpecificationsFeatureState_ = state;

    const {sets} = await this.shoppingApi_.getAllProductSpecificationsSets();
    if (!sets) {
      return;
    }
    this.allItems_ = sets;
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.listenerIds_.forEach(id => this.callbackRouter_.removeListener(id));
    if (this.focusGrid_) {
      this.focusGrid_!.destroy();
    }
  }

  private hideSyncScreen_() {
    // The sync screen shows up if the user is not syncing.
    if (this.productSpecificationsFeatureState_) {
      return this.productSpecificationsFeatureState_.isSyncingTabCompare;
    }
    return true;
  }

  private hideErrorMessage_() {
    return !this.hideSyncScreen_() || !this.hideHistoryList_();
  }

  private hideHistoryList_() {
    if (this.productSpecificationsFeatureState_) {
      // If not sync'd, not able to manage, or not in experiment,
      // then we can't even show the history list.
      return !this.productSpecificationsFeatureState_.isSyncingTabCompare ||
          !this.productSpecificationsFeatureState_.canManageSets ||
          !this.productSpecificationsFeatureState_.canLoadFullPageUi;
    }
    return true;
  }

  private updateFocusGrid_() {
    assert(this.focusGrid_);
    const items =
        this.shadowRoot!.querySelectorAll('product-specifications-item');
    for (const el of items) {
      const row = el.createFocusRow();
      this.focusGrid_!.addRow(row);
    }
    this.focusGrid_!.ensureRowActive(0);
  }

  private hasResults_(): boolean {
    return this.displayedItems_.length > 0;
  }

  private showSyncSetupFlow_() {
    assert(this.productSpecificationsFeatureState_);
    assert(!this.productSpecificationsFeatureState_.isSyncingTabCompare);

    // If signed in at the account level, then user needs to turn on sync
    // from settings.
    if (this.productSpecificationsFeatureState_.isSignedIn) {
      OpenWindowProxyImpl.getInstance().openUrl(
          'chrome://settings/syncSetup/advanced');
      return;
    }
    this.shoppingApi_.showSyncSetupFlow();
  }

  getSelectedItemCount() {
    return this.selectedItems.size;
  }

  private onItemSelected_(e: ItemCheckboxSelectEvent) {
    const index = e.detail.index;
    const itemElements =
        this.shadowRoot!.querySelectorAll('product-specifications-item');
    const toSelect =
        !this.selectedItems.has(itemElements[index].item.uuid.value);

    if (this.lastSelectedIndex_ === undefined || !e.detail.shiftKey) {
      this.changeSelection_(index, toSelect, itemElements);
      this.lastSelectedIndex_ = index;
      return;
    }

    // Handle shift selection. Change the selection state of all items between
    // |index| and |lastSelected| to the selection state of |item|.
    for (let i = Math.min(index, this.lastSelectedIndex_);
         i <= Math.min(
                  Math.max(index, this.lastSelectedIndex_),
                  this.displayedItems_.length - 1);
         i++) {
      this.changeSelection_(i, toSelect, itemElements);
    }
    this.lastSelectedIndex_ = index;
  }

  private changeSelection_(
      index: number, toSelect: boolean,
      itemElements: NodeListOf<ProductSpecificationsItemElement>) {
    if (toSelect) {
      this.selectedItems.add(this.displayedItems_[index].uuid.value);
      itemElements[index].checked = true;
    } else {
      this.selectedItems.delete(this.displayedItems_[index].uuid.value);
      itemElements[index].checked = false;
    }
  }

  /**
   * Deselect each item in |selectedItems|.
   */
  unselectAllItems() {
    this.selectedItems.clear();
    const items =
        this.shadowRoot!.querySelectorAll('product-specifications-item');
    for (const el of items) {
      el.checked = false;
    }
  }

  private onOpenMenu_(e: ItemMenuOpenEvent) {
    this.$.sharedMenu.get().showAt(e.detail.target);
    this.uuidOfOpenMenu_ = e.detail.uuid;
  }

  /**
   * Helper method to delete multiple items.
   */
  private deleteItems_(items: Set<string>): Promise<void[]> {
    // pendingDelete_ disables the delete button while a delete call
    // is being made. It waits for the call to the proxy.
    assert(!this.pendingDelete_);
    const promises: void[] = [];
    for (const uuid of items) {
      promises.push(
          this.shoppingApi_.deleteProductSpecificationsSet({value: uuid}));
    }
    this.pendingDelete_ = true;
    return Promise.all(promises);
  }

  private resetAfterDelete_() {
    this.pendingDelete_ = false;
    this.fire_('unselect-all');
  }

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

  private onRemoveItemClick_() {
    if (this.uuidOfOpenMenu_ !== null) {
      this.deleteItems_(new Set([this.uuidOfOpenMenu_.value]));
      this.closeMenu_();
      this.pendingDelete_ = false;
      this.fire_('unselect-all');
    }
  }

  /**
   * Closes the overflow menu.
   */
  private closeMenu_() {
    const menu = this.$.sharedMenu.getIfExists();
    if (menu && menu.open) {
      this.uuidOfOpenMenu_ = null;
      menu.close();
    }
  }

  getFocusGridForTesting() {
    return this.focusGrid_;
  }

  /**
   * Opens up a delete dialog from toolbar.
   */
  deleteSelectedWithPrompt() {
    // TODO: b/335670350 - add check for deleting history
    this.$.deleteItemDialog.get().showModal();
    const button =
        this.shadowRoot!.querySelector<HTMLElement>('.action-button');
    assert(button);
    button.focus();
  }

  private onDialogConfirmClick_() {
    const deleteItemDialog = this.$.deleteItemDialog.getIfExists();
    assert(deleteItemDialog);
    this.deleteItems_(this.selectedItems);
    deleteItemDialog.close();
    this.pendingDelete_ = false;
    this.fire_('unselect-all');
  }

  private onDialogCancelClick_() {
    const deleteItemDialog = this.$.deleteItemDialog.getIfExists();
    assert(deleteItemDialog);
    deleteItemDialog.close();
  }

  /**
   * Finds index of element with given uuid.
   * Returns -1 if not found.
   */
  private findIndexForSet_(uuid: Uuid): number {
    return this.allItems_.findIndex(existingSet => {
      return existingSet.uuid.value === uuid.value;
    });
  }

  private onSetUpdated_(set: ProductSpecificationsSet) {
    const setIndex = this.findIndexForSet_(set.uuid);
    if (setIndex < 0) {
      return;
    }
    this.splice('allItems_', setIndex, 1, set);
  }

  private onSetAdded_(set: ProductSpecificationsSet) {
    this.push('allItems_', set);
  }

  private onSetRemoved_(id: Uuid) {
    const setIndex = this.findIndexForSet_(id);
    if (setIndex < 0) {
      return;
    }
    this.splice('allItems_', setIndex, 1);
  }

  private computeDisplayedItems_() {
    const searchText = this.searchTerm.toLowerCase().trim();
    return this.allItems_.filter((item) => {
      return item.name.trim().toLowerCase().includes(searchText);
    });
  }

  selectOrUnselectAll() {
    if (this.displayedItems_.length === this.getSelectedItemCount()) {
      this.unselectAllItems();
    } else {
      this.selectAllItems();
    }
  }

  selectAllItems() {
    const items =
        this.shadowRoot!.querySelectorAll('product-specifications-item');
    items.forEach((item) => {
      item.checked = true;
      this.selectedItems.add(item.item.uuid.value);
    });
    assert(this.selectedItems.size === this.displayedItems_.length);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'product-specifications-lists': ProductSpecificationsListsElement;
  }
}

customElements.define(
    ProductSpecificationsListsElement.is, ProductSpecificationsListsElement);