chromium/content/browser/resources/attribution_reporting/attribution_internals_table.ts

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

import {CustomElement} from 'chrome://resources/js/custom_element.js';

import {getTemplate} from './attribution_internals_table.html.js';

export type CompareFunc<T> = (a: T, b: T) => number;

function reverse<T>(f: CompareFunc<T>): CompareFunc<T> {
  return (a, b) => f(b, a);
}

export type RenderFunc<T> = (e: HTMLElement, data: T) => void;

export interface DataColumn<T> {
  readonly label: string;
  readonly render: RenderFunc<T>;
  readonly compare?: CompareFunc<T>;
  readonly defaultSort?: boolean;
}

export type GetIdFunc<T> = (data: T, updated: boolean) => bigint|undefined;

export interface InitOpts<T> {
  readonly getId?: GetIdFunc<T>;
  readonly isSelectable?: boolean;
}

export class AttributionInternalsTableElement<T> extends CustomElement {
  static override get template() {
    return getTemplate();
  }

  private cols_?: Array<RenderFunc<T>>;
  private compare_?: CompareFunc<T>;
  private getId_?: GetIdFunc<T>;
  private styleNewRow_?: (tr: DataRowElement<T>) => void;

  init(dataCols: Iterable<DataColumn<T>>, {
    getId,
    isSelectable,
  }: InitOpts<T> = {}): void {
    this.cols_ = [];
    this.getId_ = getId;

    const tr = this.getRequiredElement('thead > tr');
    tr.addEventListener('click', e => this.onSortButtonClick_(e));

    const addTh = (content: Node|string, render: RenderFunc<T>) => {
      const th = document.createElement('th');
      th.scope = 'col';
      th.append(content);
      tr.append(th);
      this.cols_!.push(render);
      return th;
    };

    if (isSelectable) {
      const tbody = this.getRequiredElement('tbody');
      tbody.addEventListener('click', e => this.onTbodyClick(e));
      tbody.addEventListener('keydown', e => {
        if (e.code === 'Enter' || e.code === 'Space') {
          this.onTbodyClick(e);
        }
      });

      this.addEventListener(
          'rows-change',
          () => this.dispatchSelectionChange_(this.selectedData()));

      this.styleNewRow_ = tr => {
        tr.ariaSelected = 'false';
        tr.tabIndex = 0;
      };
    }

    for (const col of dataCols) {
      if (col.compare) {
        const button = new SortButtonElement(col.compare);
        button.innerText = col.label;
        button.title = `Sort by ${col.label} ascending`;

        addTh(button, col.render).ariaSort = 'none';

        if (col.defaultSort) {
          button.click();
        }
      } else {
        addTh(col.label, col.render);
      }
    }

    this.dispatchRowsChange_();
  }

  private onSortButtonClick_(e: Event): void {
    if (!(e.target instanceof SortButtonElement)) {
      return;
    }

    // Matches `ascending` and `descending` but not `none` or missing.
    const currentButton =
        this.$<HTMLElement>('thead > tr > th[aria-sort$="g"] > button');
    if (currentButton && currentButton !== e.target) {
      currentButton.title = `Sort by ${currentButton.innerText} ascending`;
      currentButton.parentElement!.ariaSort = 'none';
    }

    const th = e.target.parentElement! as HTMLElement;
    if (th.ariaSort === 'ascending') {
      th.ariaSort = 'descending';
      e.target.title = `Sort by ${e.target.innerText} ascending`;
      this.setCompare_(reverse(e.target.compare));
    } else {
      th.ariaSort = 'ascending';
      e.target.title = `Sort by ${e.target.innerText} descending`;
      this.setCompare_(e.target.compare);
    }
  }

  private rowCount_(): number {
    return this.getRequiredElement('tbody').rows.length;
  }

  private dispatchRowsChange_(): void {
    const td = this.getRequiredElement<HTMLTableCellElement>('tfoot td');
    td.colSpan = this.cols_!.length - 1;

    const rowCount = this.rowCount_();
    td.innerText = `${rowCount}`;

    this.dispatchEvent(new CustomEvent('rows-change', {
      bubbles: true,
      detail: {rowCount},
    }));
  }

  private setCompare_(f: CompareFunc<T>): void {
    this.compare_ = f;

    const tbody = this.$('tbody')!;
    Array.from(this.dataRows_())
        .sort((a, b) => f(a.data, b.data))
        .forEach(tr => tbody.append(tr));
  }

  private dataRows_(): NodeListOf<DataRowElement<T>> {
    return this.$all('tbody > tr');
  }

  private newRow_(data: T): DataRowElement<T> {
    const tr = new DataRowElement(data);
    for (const render of this.cols_!) {
      render(tr.insertCell(), data);
    }
    if (this.styleNewRow_) {
      this.styleNewRow_(tr);
    }
    return tr;
  }

  addRow(data: T): void {
    // Prevent the page from consuming ever more memory if the user leaves the
    // page open for a long time.
    // TODO(apaseltiner): This should really remove the oldest rather than clear
    // out everything.
    if (this.rowCount_() >= 1000) {
      this.clearRows();
    }

    let tr: DataRowElement<T>|undefined;

    const id = this.getId_ ? this.getId_(data, /*updated=*/ true) : undefined;
    if (id !== undefined) {
      tr = Array.prototype.find.call(
          this.dataRows_(),
          tr => id === this.getId_!(tr.data, /*updated=*/ false));

      if (tr !== undefined) {
        tr.data = data;
        this.cols_!.forEach((render, idx) => render(tr!.cells[idx]!, data));
      }
    }

    if (tr === undefined) {
      tr = this.newRow_(data);
    }

    let nextTr: DataRowElement<T>|undefined;
    if (this.compare_) {
      // TODO(apaseltiner): Use binary search.
      nextTr = Array.prototype.find.call(
          this.dataRows_(), tr => this.compare_!(tr.data, data) > 0);
    }

    if (nextTr) {
      nextTr.before(tr);
    } else {
      this.$('tbody')!.append(tr);
    }

    this.dispatchRowsChange_();
  }

  updateRows(updatedDatas: Iterable<T>): void {
    const updatedDatasById = new Map<bigint, T>();
    const trs: Array<DataRowElement<T>> = [];

    for (const data of updatedDatas) {
      const id = this.getId_!(data, /*updated=*/ true);
      if (id === undefined) {
        trs.push(this.newRow_(data));
      } else {
        updatedDatasById.set(id, data);
      }
    }

    for (const tr of this.dataRows_()) {
      const id = this.getId_!(tr.data, /*updated=*/ false);
      if (id === undefined) {
        trs.push(tr);
      } else {
        const updatedData = updatedDatasById.get(id);
        if (updatedData === undefined) {
          tr.remove();
        } else {
          updatedDatasById.delete(id);
          tr.data = updatedData;
          this.cols_!.forEach(
              (render, idx) => render(tr.cells[idx]!, updatedData));
          trs.push(tr);
        }
      }
    }

    for (const data of updatedDatasById.values()) {
      trs.push(this.newRow_(data));
    }

    if (this.compare_) {
      trs.sort((a, b) => this.compare_!(a.data, b.data));
    }

    const tbody = this.$('tbody')!;
    for (const tr of trs) {
      tbody.append(tr);
    }

    this.dispatchRowsChange_();
  }

  clearRows(shouldDelete?: (data: T) => boolean): void {
    if (shouldDelete) {
      for (const tr of this.dataRows_()) {
        if (shouldDelete(tr.data)) {
          tr.remove();
        }
      }
    } else {
      this.$('tbody')!.replaceChildren();
    }
    this.dispatchRowsChange_();
  }

  private selectedRow_(): DataRowElement<T>|null {
    return this.$('tbody > tr[aria-selected="true"]');
  }

  selectedData(): T|undefined {
    return this.selectedRow_()?.data;
  }

  clearSelection(): void {
    const tr = this.selectedRow_();
    if (tr) {
      tr.ariaSelected = 'false';
      this.dispatchSelectionChange_(undefined);
    }
  }

  private dispatchSelectionChange_(data: T|undefined): void {
    this.dispatchEvent(new CustomEvent('selection-change', {detail: {data}}));
  }

  private onTbodyClick(e: Event): void {
    if (!(e.target instanceof HTMLElement) ||
        e.target instanceof HTMLAnchorElement) {
      return;
    }
    const tr = e.target.closest('tr');
    const selectedTr = this.selectedRow_();
    if (!(tr instanceof DataRowElement) || tr === selectedTr) {
      return;
    }
    if (selectedTr) {
      selectedTr.ariaSelected = 'false';
    }
    tr.ariaSelected = 'true';
    this.dispatchSelectionChange_(tr.data);
  }
}

customElements.define(
    'attribution-internals-table', AttributionInternalsTableElement);

class DataRowElement<T> extends HTMLTableRowElement {
  constructor(public data: T) {
    super();
  }
}

customElements.define(
    'attribution-internals-data-row', DataRowElement, {extends: 'tr'});

class SortButtonElement<T> extends HTMLButtonElement {
  constructor(readonly compare: CompareFunc<T>) {
    super();
  }
}

customElements.define(
    'attribution-internals-sort-button', SortButtonElement,
    {extends: 'button'});

declare global {
  interface HTMLElementEventMap {
    'rows-change': CustomEvent<{rowCount: number}>;
    'selection-change': CustomEvent<{data: any | undefined}>;
  }
}