chromium/components/sync/service/resources/sync_search.ts

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

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

import type {SyncNode, SyncNodeMap} from './chrome_sync.js';
import {getAllNodes, Timer} from './chrome_sync.js';

const ERROR_ATTR: string = 'error';
const SELECTED_ATTR: string = 'selected';

export class SyncSearchManager {
  private currSearchId_: number = 0;
  private resultsData_: object[] = [];
  private selected_: HTMLElement|null = null;
  private selectedIndex_: number = -1;
  private resultsControl_: HTMLElement;
  private detailsControl_: HTMLElement;
  private queryControl_: HTMLInputElement;
  private statusControl_: HTMLElement;

  /**
   * @param queryControl The <input> object of
   *     type=search where the user's query is typed.
   * @param submitControl The <button> object
   *     where the user can click to submit the query.
   * @param statusControl The <span> object display the
   *     search status.
   * @param resultsControl The <list> object which holds
   *     the list of returned results.
   * @param detailsControl The <pre> object which
   *     holds the details of the selected result.
   */
  constructor(
      queryControl: HTMLInputElement, submitControl: HTMLButtonElement,
      statusControl: HTMLElement, resultsControl: HTMLElement,
      detailsControl: HTMLElement) {
    this.resultsControl_ = resultsControl;
    this.detailsControl_ = detailsControl;
    this.queryControl_ = queryControl;
    this.statusControl_ = statusControl;

    submitControl.addEventListener('click', () => this.startSearch_());
    // Decorate search box.
    this.queryControl_.addEventListener('search', () => this.startSearch_());
    this.queryControl_.value = '';
    this.resultsControl_.setAttribute('role', 'list');
    this.resultsControl_.tabIndex = 0;
    this.resultsControl_.addEventListener(
        'keydown', e => this.handleKeydown_(e));
  }

  private startSearch_() {
    const query = this.queryControl_.value;
    this.statusControl_.textContent = '';
    this.resultsData_ = [];
    this.drawResultsList_();
    if (!query) {
      return;
    }

    this.statusControl_.textContent = 'Searching for ' + query + '...';
    this.queryControl_.toggleAttribute(ERROR_ATTR, false);
    this.doSearch_(query);
  }

  private setSelected_(newSelected: HTMLElement, newIndex: number) {
    if (this.selected_) {
      this.selected_.toggleAttribute(SELECTED_ATTR, false);
    }
    newSelected.toggleAttribute(SELECTED_ATTR, true);
    this.selected_ = newSelected;
    this.selectedIndex_ = newIndex;
    this.detailsControl_.textContent =
        JSON.stringify(this.resultsData_[this.selectedIndex_], null, 2);
  }

  private handleKeydown_(e: KeyboardEvent) {
    let newIndex: number = -1;
    if (e.key === 'ArrowUp') {
      newIndex = this.selectedIndex_ === -1 ?
          this.resultsData_.length - 1 :
          Math.min(0, this.selectedIndex_ - 1);
    } else if (
        e.key === 'ArrowDown' &&
        this.selectedIndex_ < this.resultsData_.length - 1) {
      newIndex = this.selectedIndex_ === -1 ? 0 : this.selectedIndex_ + 1;
    } else if (e.key === 'Home') {
      newIndex = 0;
    } else if (e.key === 'End') {
      newIndex = this.resultsData_.length - 1;
    }

    if (newIndex === -1) {
      return;
    }

    const items = this.resultsControl_.querySelectorAll('li');
    this.setSelected_(items[newIndex]!, newIndex);
    assert(this.selected_);
    this.selected_.scrollIntoViewIfNeeded();
    e.preventDefault();
  }

  private drawResultsList_() {
    this.selected_ = null;
    this.selectedIndex_ = -1;
    this.resultsControl_.innerHTML =
        window.trustedTypes ? window.trustedTypes.emptyHTML : '';
    this.resultsData_.forEach((item: object, index: number) => {
      const li = document.createElement('li');
      li.setAttribute('role', 'listitem');
      li.textContent = item.toString();
      this.resultsControl_.appendChild(li);
      li.addEventListener('click', () => {
        this.setSelected_(li, index);
      });
    });
  }

  /**
   * Runs a search with the given query.
   * @param query The regex to do the search with.
   */
  private doSearch_(query: string) {
    const timer = new Timer();
    this.currSearchId_++;
    const searchId = this.currSearchId_;
    try {
      const regex = new RegExp(query);
      getAllNodes((nodeMap: SyncNodeMap) => {
        // Put all nodes into one big list that ignores the type.
        const nodes: SyncNode[] =
            nodeMap.map(x => x.nodes).reduce((a, b) => a.concat(b));
        if (this.currSearchId_ !== searchId) {
          return;
        }
        this.displayResults_(
            timer, nodes.filter(function(elem) {
              return regex.test(JSON.stringify(elem, null, 2));
            }),
            null);
      });
    } catch (err) {
      // Sometimes the provided regex is invalid.  This and other errors will
      // be caught and handled here.
      this.displayResults_(timer, [], err as Error);
    }
  }

  private displayResults_(
      timer: Timer, nodes: Array<{NON_UNIQUE_NAME: string}>,
      error: Error|null) {
    if (error) {
      this.statusControl_.textContent = 'Error: ' + error;
      this.queryControl_.toggleAttribute(ERROR_ATTR, true);
    } else {
      this.statusControl_.textContent = 'Found ' + nodes.length + ' nodes in ' +
          timer.getElapsedSeconds() + 's';
      this.queryControl_.toggleAttribute(ERROR_ATTR, false);

      // TODO(akalin): Write a nicer list display.
      for (let i = 0; i < nodes.length; ++i) {
        nodes[i]!.toString = function() {
          return this.NON_UNIQUE_NAME;
        };
      }
      this.resultsData_.push(...nodes);
      this.drawResultsList_();
    }
  }

  setDataForTest(data: object[]) {
    this.resultsData_ = data;
    this.drawResultsList_();
  }
}

function createDoQueryFunction(
    queryControl: HTMLInputElement, submitControl: HTMLButtonElement,
    query: string): () => void {
  return function() {
    queryControl.value = query;
    submitControl.click();
  };
}

/**
 * Decorates the quick search controls
 *
 * @param quickLinkArray The <a> object which
 *     will be given a link to a quick filter option.
 * @param submitControl
 * @param queryControl The <input> object of type=search where user's query is
 *     typed.
 */
export function decorateQuickQueryControls(
    quickLinkArray: NodeListOf<HTMLElement>, submitControl: HTMLButtonElement,
    queryControl: HTMLInputElement) {
  for (let index = 0; index < quickLinkArray.length; ++index) {
    const quickQuery = quickLinkArray[index]!.getAttribute('data-query');
    assert(quickQuery);
    const quickQueryFunction =
        createDoQueryFunction(queryControl, submitControl, quickQuery);
    quickLinkArray[index]!.addEventListener('click', quickQueryFunction);
  }
}