chromium/components/flags_ui/resources/experiment.ts

// Copyright 2023 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 {CustomElement} from 'chrome://resources/js/custom_element.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';

import {getTemplate} from './experiment.html.js';
import type {Feature} from './flags_browser_proxy.js';
import {FlagsBrowserProxyImpl} from './flags_browser_proxy.js';

/**
 * Parses the element's text content to find the matching search term.
 * If a match is found, handles highlighting the substring.
 * @param searchTerm The query to search for.
 * @param element The element to search on and highlight.
 * @return Whether or not a match was found.
 */
function highlightMatch(searchTerm: string, element: HTMLElement): boolean {
  const text = element.textContent!;
  const match = text.toLowerCase().indexOf(searchTerm);

  if (match === -1) {
    return false;
  }

  if (searchTerm !== '') {
    // Clear all nodes.
    element.textContent = '';

    if (match > 0) {
      const textNodePrefix = document.createTextNode(text.substring(0, match));
      element.appendChild(textNodePrefix);
    }

    const matchEl = document.createElement('mark');
    matchEl.textContent = text.substr(match, searchTerm.length);
    element.appendChild(matchEl);

    const matchSuffix = text.substring(match + searchTerm.length);
    if (matchSuffix) {
      const textNodeSuffix = document.createTextNode(matchSuffix);
      element.appendChild(textNodeSuffix);
    }
  }
  return true;
}

/**
 * Reset existing highlights on an element.
 * @param element The element to remove all highlighted mark up on.
 */
function resetHighlights(element: HTMLElement) {
  if (element.children) {
    // Clear child <mark> from element, preserving inner text.
    element.textContent = element.textContent;
  }
}


export class FlagsExperimentElement extends CustomElement {
  private feature_: Feature|null = null;

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

  set data(feature: Feature) {
    this.feature_ = feature;

    const container = this.getRequiredElement('.experiment');
    container.id = feature.internal_name;

    const experimentDefault = this.getRequiredElement('.experiment-default');
    experimentDefault.classList.toggle(
        'experiment-default', feature.is_default);
    experimentDefault.classList.toggle(
        'experiment-switched', !feature.is_default);

    const experimentName = this.getRequiredElement('.experiment-name');
    experimentName.id = `${feature.internal_name}_name`;
    experimentName.title =
        feature.is_default ? '' : loadTimeData.getString('experiment-enabled');
    experimentName.textContent = feature.name;

    const description = this.getRequiredElement('.description');
    description.textContent = feature.description;
    const platforms = this.getRequiredElement('.platforms');
    platforms.textContent = feature.supported_platforms.join(', ');

    if (feature.links) {
      for (const link of feature.links) {
        const linkElement = document.createElement('a');
        linkElement.href = link;
        linkElement.textContent = link;
        this.getRequiredElement('.links-container').appendChild(linkElement);
      }
    }

    if (feature.origin_list_value !== undefined) {
      const textarea = document.createElement('textarea');
      textarea.dataset['internalName'] = feature.internal_name;
      textarea.classList.add('experiment-origin-list-value');
      textarea.value = feature.origin_list_value;
      textarea.setAttribute('aria-labelledby', `${feature.internal_name}_name`);
      textarea.onchange = (e) => {
        e.stopPropagation();
        this.handleSetOriginListFlag_(textarea.value);
        textarea.dispatchEvent(new Event('textarea-change', {
          bubbles: true,
          composed: true,
        }));
      };
      this.getRequiredElement('.textarea-container').appendChild(textarea);
    }

    if (feature.string_value !== undefined) {
      const textbox = document.createElement('input');
      textbox.dataset['internalName'] = feature.internal_name;
      textbox.value = feature.string_value;
      textbox.setAttribute('aria-labelledby', `${feature.internal_name}_name`);
      textbox.onchange = (e) => {
        e.stopPropagation();
        this.handleSetStringFlag_(textbox.value);
        textbox.dispatchEvent(new Event('input-change', {
          bubbles: true,
          composed: true,
        }));
      };
      this.getRequiredElement('.input-container').appendChild(textbox);
    }

    const permalink = this.getRequiredElement<HTMLAnchorElement>('.permalink');
    permalink.href = `#${feature.internal_name}`;
    permalink.textContent = `#${feature.internal_name}`;

    const smallScreenCheck = window.matchMedia('(max-width: 480px)');
    // Toggling of experiment description overflow content on smaller screens.
    const expandContainer = this.getRequiredElement('.flex:first-child');
    if (smallScreenCheck.matches) {
      expandContainer.onclick = () =>
          expandContainer.classList.toggle('expand');
    }

    if (this.hasAttribute('unsupported')) {
      this.getRequiredElement('.experiment-actions').textContent =
          loadTimeData.getString('not-available-platform');
      return;
    }

    if (feature.options && feature.options.length > 0) {
      const experimentSelect = document.createElement('select');
      experimentSelect.dataset['internalName'] = feature.internal_name;
      experimentSelect.classList.add('experiment-select');
      experimentSelect.disabled = feature.enabled === false;
      experimentSelect.setAttribute(
          'aria-labelledby', `${feature.internal_name}_name`);

      for (let i = 0; i < feature.options.length; i++) {
        const option = feature.options[i]!;
        const optionEl = document.createElement('option');
        optionEl.selected = option.selected;
        optionEl.textContent = option.description;
        experimentSelect.appendChild(optionEl);
      }

      experimentSelect.onchange = (e) => {
        e.stopPropagation();
        this.handleSelectExperimentalFeatureChoice_(
            experimentSelect.selectedIndex);
        experimentSelect.dispatchEvent(new Event('select-change', {
          bubbles: true,
          composed: true,
        }));
      };

      this.getRequiredElement('.experiment-actions')
          .appendChild(experimentSelect);
      return;
    }

    assert(feature.options === undefined || feature.options.length === 0);
    const experimentEnableDisable = document.createElement('select');
    experimentEnableDisable.dataset['internalName'] = feature.internal_name;
    experimentEnableDisable.classList.add('experiment-enable-disable');
    experimentEnableDisable.setAttribute(
        'aria-labelledby', `${feature.internal_name}_name`);

    const disabledOptionEl = document.createElement('option');
    disabledOptionEl.value = 'disabled';
    disabledOptionEl.selected = !feature.enabled;
    disabledOptionEl.textContent = loadTimeData.getString('disabled');
    experimentEnableDisable.appendChild(disabledOptionEl);

    const enabledOptionEl = document.createElement('option');
    enabledOptionEl.value = 'enabled';
    enabledOptionEl.selected = feature.enabled;
    enabledOptionEl.textContent = loadTimeData.getString('enabled');
    experimentEnableDisable.appendChild(enabledOptionEl);

    experimentEnableDisable.onchange = (e) => {
      e.stopPropagation();
      const selectedIndex = experimentEnableDisable.selectedIndex;
      const selectedOption = experimentEnableDisable.options[selectedIndex]!;
      this.handleEnableExperimentalFeature_(selectedOption.value === 'enabled');
      experimentEnableDisable.dispatchEvent(new Event('select-change', {
        bubbles: true,
        composed: true,
      }));
    };

    this.getRequiredElement('.experiment-actions')
        .appendChild(experimentEnableDisable);
  }

  getSelect(): HTMLSelectElement|null {
    return this.$('select');
  }

  getTextarea(): HTMLTextAreaElement|null {
    return this.$('textarea');
  }

  getTextbox(): HTMLInputElement|null {
    return this.$('input');
  }

  /**
   * Looks for and highlights the first match on any of the
   * component's title, body, and permalink text. Resets
   * preexisting highlights.
   * @param searchTerm The query to search for.
   * @return Whether or not a match was found.
   */
  match(searchTerm: string): boolean {
    const title = this.getRequiredElement('.experiment-name');
    const body = this.getRequiredElement('.body');
    const permalink = this.getRequiredElement('.permalink');

    resetHighlights(title);
    resetHighlights(body);
    resetHighlights(permalink);

    return highlightMatch(searchTerm, title) ||
        highlightMatch(searchTerm, body) ||
        highlightMatch(searchTerm.replace(/\s/, '-'), permalink);
  }


  /**
   * Sets style depending on the selected option.
   * @param isDefault Whether or not the default option is selected.
   */
  private updateDefaultStyle_(isDefault: boolean) {
    const experimentContainer =
        this.getRequiredElement('.experiment-default, .experiment-switched');
    experimentContainer.classList.toggle('experiment-default', isDefault);
    experimentContainer.classList.toggle('experiment-switched', !isDefault);
  }

  /**
   * Handles a 'enable' or 'disable' button getting clicked.
   * @param enable Whether to enable or disable the experiment.
   */
  private handleEnableExperimentalFeature_(enable: boolean) {
    /* This function is an onchange handler, which can be invoked during page
     * restore - see https://crbug.com/1038638. */
    assert(this.feature_);
    assert(!this.feature_.options || this.feature_.options.length === 0);

    FlagsBrowserProxyImpl.getInstance().enableExperimentalFeature(
        this.feature_.internal_name, enable);

    const isDefault = enable ?
        (this.feature_.is_default === this.feature_.enabled) :
        (this.feature_.is_default !== this.feature_.enabled);

    this.updateDefaultStyle_(isDefault);
  }

  /**
   * Invoked when the selection of a multi-value choice is changed to the
   * specified index.
   * @param index The index of the option that was selected.
   */
  private handleSelectExperimentalFeatureChoice_(index: number) {
    /* This function is an onchange handler, which can be invoked during page
     * restore - see https://crbug.com/1038638. */
    assert(this.feature_);
    assert(this.feature_.options && this.feature_.options.length > 0);

    FlagsBrowserProxyImpl.getInstance().selectExperimentalFeature(
        this.feature_.internal_name, index);
    this.updateDefaultStyle_(index === 0);
  }

  /**
   * Invoked when the value of a textarea for origins is changed.
   * @param value The value of the textarea.
   */
  private handleSetOriginListFlag_(value: string) {
    /* This function is an onchange handler, which can be invoked during page
     * restore - see https://crbug.com/1038638. */
    assert(this.feature_);
    assert(this.feature_.origin_list_value !== undefined);

    FlagsBrowserProxyImpl.getInstance().setOriginListFlag(
        this.feature_.internal_name, value);
  }

  /**
   * Invoked when the value of an input is changed.
   * @param value The value of the input.
   */
  private handleSetStringFlag_(value: string) {
    /* This function is an onchange handler, which can be invoked during page
     * restore - see https://crbug.com/1038638. */
    assert(this.feature_);

    FlagsBrowserProxyImpl.getInstance().setStringFlag(
        this.feature_.internal_name, value);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'flags-experiment': FlagsExperimentElement;
  }
}

customElements.define('flags-experiment', FlagsExperimentElement);