chromium/chrome/browser/resources/commerce/product_specifications/table.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 'chrome://resources/cr_elements/cr_auto_img/cr_auto_img.js';
import './description_section.js';
import './product_selector.js';
import './buying_options_section.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_icons.css.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_hidden_style.css.js';
import 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';

import {assert} from '//resources/js/assert.js';
import {getFaviconForPageURL} from '//resources/js/icon.js';
import type {DomRepeat} from '//resources/polymer/v3_0/polymer/lib/elements/dom-repeat.js';
import type {BrowserProxy} from 'chrome://resources/cr_components/commerce/browser_proxy.js';
import {BrowserProxyImpl} from 'chrome://resources/cr_components/commerce/browser_proxy.js';
import type {DomRepeatEvent} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {Content, TableColumn} from './app.js';
import type {BuyingOptionsLink} from './buying_options_section.js';
import type {ProductDescription} from './description_section.js';
import {DragAndDropManager} from './drag_and_drop_manager.js';
import type {SectionType} from './product_selection_menu.js';
import {getTemplate} from './table.html.js';
import {WindowProxy} from './window_proxy.js';

export interface TableElement {
  $: {
    table: HTMLElement,
    columnRepeat: DomRepeat,
  };
}

export class TableElement extends PolymerElement {
  static get is() {
    return 'product-specifications-table';
  }

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

  static get properties() {
    return {
      columns: {
        type: Array,
        observer: 'onColumnsChanged_',
      },
      draggingColumn: HTMLElement,
      hoveredColumnIndex_: Number,
    };
  }

  columns: TableColumn[];
  draggingColumn: HTMLElement|null = null;
  private hoveredColumnIndex_: number|null = null;

  private dragAndDropManager_: DragAndDropManager = new DragAndDropManager();
  private shoppingApi_: BrowserProxy = BrowserProxyImpl.getInstance();

  override connectedCallback(): void {
    super.connectedCallback();
    this.dragAndDropManager_.init(this);
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    this.dragAndDropManager_.destroy();
  }

  // Called by |dragAndDropManager|.
  moveColumnOnDrop(fromIndex: number, dropIndex: number) {
    const columns = this.columns;
    const [draggingColumn] = columns.splice(fromIndex, 1);
    assert(draggingColumn);
    columns.splice(dropIndex, 0, draggingColumn);
    this.notifySplices('columns', [
      {
        index: fromIndex,
        removed: [draggingColumn],
        addedCount: 0,
        object: columns,
        type: 'splice',
      },
      {
        index: dropIndex,
        removed: [],
        addedCount: 1,
        object: columns,
        type: 'splice',
      },
    ]);

    this.dispatchEvent(new Event('url-order-update'));
  }

  private onColumnsChanged_() {
    this.style.setProperty('--num-columns', String(this.columns.length));
  }

  // |this.draggingColumn| is set by |dragAndDropManager|.
  private isDragging_(columnIndex: number): boolean {
    return !!this.draggingColumn &&
        columnIndex ===
        (this.$.columnRepeat.modelForElement(this.draggingColumn) as unknown as
         {
           columnIndex: number,
         }).columnIndex;
  }

  private isFirstColumn_(columnIndex: number): boolean|undefined {
    if (!this.draggingColumn) {
      return columnIndex === 0;
    }
    // While dragging, |dragAndDropManager| toggles this attribute, as the first
    // column shown in the table may have a non-zero `columnIndex`.
    return undefined;
  }

  private getScrollSnapAlign_(): string {
    return !this.draggingColumn ? 'start' : 'none';
  }

  // Determines the number of rows needed in the grid layout.
  // This is the sum of:
  //   - 1 row for the product selector.
  //   - 1 row for the image container.
  //   - Number of product details.
  private getRowCount_(numProductDetails: number): number {
    return 2 + numProductDetails;
  }

  private getUrls_() {
    return this.columns.map(column => column.selectedItem.url);
  }

  private onHideOpenTabButton_() {
    this.hoveredColumnIndex_ = null;
  }

  private onShowOpenTabButton_(e: DomRepeatEvent<TableColumn>&
                               {model: {columnIndex: number}}) {
    this.hoveredColumnIndex_ = e.model.columnIndex;
  }

  private showOpenTabButton_(columnIndex: number): boolean {
    return !this.draggingColumn && this.hoveredColumnIndex_ !== null &&
        this.hoveredColumnIndex_ === columnIndex;
  }

  private onOpenTabButtonClick_(e: DomRepeatEvent<TableColumn>&
                                {model: {columnIndex: number}}) {
    if (!WindowProxy.getInstance().onLine) {
      this.dispatchEvent(new Event('unavailable-action-attempted'));
      return;
    }
    this.shoppingApi_.switchToOrOpenTab(
        {url: this.columns[e.model.columnIndex].selectedItem.url});
  }

  private onSelectedUrlChange_(
      e: DomRepeatEvent<
          TableColumn, CustomEvent<{url: string, urlSection: SectionType}>>&
      {model: {columnIndex: number}}) {
    this.dispatchEvent(new CustomEvent('url-change', {
      bubbles: true,
      composed: true,
      detail: {
        url: e.detail.url,
        urlSection: e.detail.urlSection,
        index: e.model.columnIndex,
      },
    }));
  }

  private onUrlRemove_(e: DomRepeatEvent<TableColumn>&
                       {model: {columnIndex: number}}) {
    this.dispatchEvent(new CustomEvent('url-remove', {
      bubbles: true,
      composed: true,
      detail: {
        index: e.model.columnIndex,
      },
    }));
  }

  private showRow_(title: string, rowIndex: number): boolean {
    return this.rowHasNonEmptyAttributes_(title, rowIndex) ||
        this.rowHasNonEmptySummary_(title, rowIndex) ||
        this.rowHasText_(title, rowIndex) ||
        this.rowHasBuyingOptions_(rowIndex);
  }

  private rowHasText_(title: string, rowIndex: number): boolean {
    const rowDetails = this.columns.map(
        column => column.productDetails && column.productDetails[rowIndex]);

    return rowDetails.some(
        detail => detail && detail.title === title &&
            this.contentIsString_(detail.content));
  }

  private rowHasNonEmptyAttributes_(title: string, rowIndex: number): boolean {
    const rowDetails = this.columns.map(
        column => column.productDetails && column.productDetails[rowIndex]);

    return rowDetails.some(detail => {
      if (!detail || detail.title !== title ||
          !this.contentIsProductDescription_(detail.content)) {
        return false;
      }
      return detail.content && detail.content.attributes.some(attr => {
        return attr.value.length > 0 && attr.value !== 'N/A';
      });
    });
  }

  private rowHasNonEmptySummary_(title: string, rowIndex: number): boolean {
    const rowDetails = this.columns.map(
        column => column.productDetails && column.productDetails[rowIndex]);

    return rowDetails.some(detail => {
      return detail && detail.title === title && detail.content &&
          this.contentIsProductDescription_(detail.content) &&
          detail.content.summary.length > 0 &&
          detail.content.summary.some(
              (summaryObj) => summaryObj.text !== 'N/A');
    });
  }

  private rowHasBuyingOptions_(rowIndex: number): boolean {
    const rowDetails = this.columns.map(
        column => column.productDetails && column.productDetails[rowIndex]);

    return rowDetails.some(
        detail => detail && this.contentIsBuyingOptionsLink_(detail.content));
  }

  private filterProductDescription_(
      productDesc: ProductDescription, title: string,
      rowIndex: number): ProductDescription {
    // Hide product descriptions when all attributes/summaries in this row are
    // missing or marked "N/A".
    return {
      attributes: this.rowHasNonEmptyAttributes_(title, rowIndex) ?
          productDesc.attributes :
          [],
      summary: this.rowHasNonEmptySummary_(title, rowIndex) ?
          productDesc.summary :
          [],
    };
  }

  private contentIsString_(content: Content): content is string {
    return (content && typeof content === 'string') as boolean;
  }

  private contentIsProductDescription_(content: Content):
      content is ProductDescription {
    if (content) {
      const description = content as ProductDescription;
      return description.attributes && description.summary &&
          (description.attributes.length > 0 || description.summary.length > 0);
    }
    return false;
  }

  private contentIsBuyingOptionsLink_(content: Content):
      content is BuyingOptionsLink {
    if (content) {
      const buyingOptions = content as BuyingOptionsLink;
      return (buyingOptions.jackpotUrl &&
              buyingOptions.jackpotUrl.length > 0) as boolean;
    }
    return false;
  }

  // This method provides a string that is intended to be used primarily in CSS.
  private getFavicon_(url: string): string {
    return getFaviconForPageURL(url, false, '', 32);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'product-specifications-table': TableElement;
  }
}

customElements.define(TableElement.is, TableElement);