chromium/ash/webui/shimless_rma/resources/onboarding_select_components_page.ts

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

import './base_page.js';
import './repair_component_chip.js';
import './shimless_rma_shared.css.js';

import {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {ComponentTypeToId} from './data.js';
import {CLICK_REPAIR_COMPONENT_BUTTON, ClickRepairComponentButtonEvent} from './events.js';
import {getShimlessRmaService} from './mojo_interface_provider.js';
import {getTemplate} from './onboarding_select_components_page.html.js';
import {RepairComponentChip} from './repair_component_chip.js';
import {Component, ComponentRepairStatus, ComponentType, ShimlessRmaServiceInterface, StateResult} from './shimless_rma.mojom-webui.js';
import {enableNextButton, executeThenTransitionState, focusPageTitle} from './shimless_rma_util.js';

interface ComponentCheckbox {
  component: ComponentType;
  uniqueId: number;
  id: string;
  identifier: string;
  name: string;
  checked: boolean;
  disabled: boolean;
  isFirstClickableComponent?: boolean;
}

declare global {
  interface HTMLElementEventMap {
    [CLICK_REPAIR_COMPONENT_BUTTON]: ClickRepairComponentButtonEvent;
  }
}

/**
 * @fileoverview
 * 'onboarding-select-components-page' is the page for selecting the components
 * that were replaced during repair.
 */

const NUM_COLUMNS = 2;

const OnboardingSelectComponentsPageElementBase = I18nMixin(PolymerElement);

export class OnboardingSelectComponentsPageElement extends
    OnboardingSelectComponentsPageElementBase {
  static get is() {
    return 'onboarding-select-components-page' as const;
  }

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

  static get properties() {
    return {
      /**
       * Set by shimless_rma.ts.
       */
      allButtonsDisabled: Boolean,

      componentCheckboxes: {
        type: Array,
        value: () => [],
      },

      reworkFlowLinkText: {type: String, value: ''},

      /**
       * The index into componentCheckboxes for keyboard navigation between
       * components.
       */
      focusedComponentIndex: {
        type: Number,
        value: -1,
      },
    };
  }

  allButtonsDisabled: boolean;
  shimlessRmaService: ShimlessRmaServiceInterface = getShimlessRmaService();
  protected componentCheckboxes: ComponentCheckbox[];
  private reworkFlowLinkText: TrustedHTML;
  private focusedComponentIndex: number;
  private onComponentClickedListener: EventListenerOrEventListenerObject|null =
      null;

  static get observers() {
    return ['updateIsFirstClickableComponent(componentCheckboxes.*)'];
  }

  override connectedCallback() {
    super.connectedCallback();
    this.focusOnCurrentComponent();
    window.addEventListener('keydown', this.handleKeyDownEvent);
    this.onComponentClickedListener = (e) =>
        this.componentClicked((e as ClickRepairComponentButtonEvent));
    window.addEventListener(
        CLICK_REPAIR_COMPONENT_BUTTON, this.onComponentClickedListener);
  }

  /**
   * The componentClickedCallback callback is used to capture events when
   * components are clicked, so that the page can put the focus on the
   * component that was clicked.
   */
  private componentClicked(event: ClickRepairComponentButtonEvent): void {
    const componentIndex = this.componentCheckboxes.findIndex(
        component => component.uniqueId === event.detail);

    if (componentIndex === -1 ||
        this.componentCheckboxes[componentIndex].disabled) {
      return;
    }

    this.focusedComponentIndex = componentIndex;
    this.focusOnCurrentComponent();
  }

  /**
   * Handles keyboard navigation over the list of components.
   */
  private handleKeyDownEvent(event: KeyboardEvent): void {
    if (event.key !== 'ArrowRight' && event.key !== 'ArrowDown' &&
        event.key !== 'ArrowLeft' && event.key !== 'ArrowUp') {
      return;
    }

    // If there are no selectable components, do nothing.
    if (this.focusedComponentIndex === -1) {
      return;
    }

    // Don't use keyboard navigation if the user tabbed out of the
    // component list.
    if (!this.shadowRoot!.activeElement ||
        this.shadowRoot!.activeElement.tagName !== 'REPAIR-COMPONENT-CHIP') {
      return;
    }

    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
      // The Down button should send you down the column, so we go forward
      // by two components, which is the size of the row.
      let step = 1;
      if (event.key === 'ArrowDown') {
        step = NUM_COLUMNS;
      }

      let newIndex = this.focusedComponentIndex + step;
      // Keep skipping disabled components until we encounter one that is
      // not disabled.
      while (newIndex < this.componentCheckboxes.length &&
             this.componentCheckboxes[newIndex].disabled) {
        newIndex += step;
      }
      // Check that we haven't ended up outside of the array before
      // applying the changes.
      if (newIndex < this.componentCheckboxes.length) {
        this.focusedComponentIndex = newIndex;
      }
    }

    // The left and up arrows work similarly to down and right, but go
    // backwards.
    if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
      let step = 1;
      if (event.key === 'ArrowUp') {
        step = NUM_COLUMNS;
      }

      let newIndex = this.focusedComponentIndex - step;
      while (newIndex >= 0 && this.componentCheckboxes[newIndex].disabled) {
        newIndex -= step;
      }
      if (newIndex >= 0) {
        this.focusedComponentIndex = newIndex;
      }
    }

    this.focusOnCurrentComponent();
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    window.removeEventListener('keydown', this.handleKeyDownEvent);
    if (this.onComponentClickedListener) {
      window.removeEventListener(
          CLICK_REPAIR_COMPONENT_BUTTON, this.onComponentClickedListener);
    }
  }

  override ready() {
    super.ready();
    this.setReworkFlowLink();
    this.getComponents();
    enableNextButton(this);

    // Hide the gradient when the list is scrolled to the end.
    const scrollContainer = this.shadowRoot!.querySelector('.scroll-container');
    assert(scrollContainer);
    scrollContainer.addEventListener('scroll', (event: Event) => {
      assert(event && event.target);
      const target = event.target as HTMLElement;
      const gradient: HTMLDivElement|null =
          this.shadowRoot!.querySelector('.gradient');
      assert(gradient);
      if (target.scrollHeight - target.scrollTop === target.clientHeight) {
        gradient.style.setProperty('visibility', 'hidden');
      } else {
        gradient.style.setProperty('visibility', 'visible');
      }
    });

    focusPageTitle(this);
  }

  private async getComponents(): Promise<void> {
    const result = await this.shimlessRmaService.getComponentList();
    if (!result || !result.hasOwnProperty('components')) {
      console.error('Could not get components!');
      return;
    }

    this.componentCheckboxes =
        result.components.map((item: Component, index: number) => {
          assert(item.component);
          return {
            component: item.component,
            uniqueId: index,
            id: ComponentTypeToId[item.component],
            identifier: item.identifier,
            name: this.i18n(ComponentTypeToId[item.component]),
            checked: item.state === ComponentRepairStatus.kReplaced,
            disabled: item.state === ComponentRepairStatus.kMissing,
          };
        });

    // Focus on the first clickable component at the beginning.
    this.focusedComponentIndex =
        this.componentCheckboxes.findIndex(component => !component.disabled);
  }

  /**
   * Make the page focus on the component at focusedComponentIndex.
   */
  private focusOnCurrentComponent(): void {
    if (this.focusedComponentIndex !== -1) {
      const componentChip: RepairComponentChip|null =
          this.shadowRoot!.querySelector(`[unique-id="${
              this.componentCheckboxes[this.focusedComponentIndex]
                  .uniqueId}"]`);
      assert(componentChip);
      const componentButton: CrButtonElement|null =
          componentChip.shadowRoot!.querySelector('#componentButton');
      assert(componentButton);
      componentButton.focus();
    }
  }

  private getComponentRepairStateList(): Component[] {
    return this.componentCheckboxes.map((item: ComponentCheckbox) => {
      let state = ComponentRepairStatus.kOriginal;
      if (item.disabled) {
        state = ComponentRepairStatus.kMissing;
      } else if (item.checked) {
        state = ComponentRepairStatus.kReplaced;
      }
      return {
        component: item.component,
        state: state,
        identifier: item.identifier,
      };
    });
  }

  protected onReworkFlowLinkClicked(e: Event): void {
    e.preventDefault();
    executeThenTransitionState(
        this, () => this.shimlessRmaService.reworkMainboard());
  }

  onNextButtonClick(): Promise<{stateResult: StateResult}> {
    return this.shimlessRmaService.setComponentList(
        this.getComponentRepairStateList());
  }

  protected setReworkFlowLink(): void {
    this.reworkFlowLinkText =
        this.i18nAdvanced('reworkFlowLinkText', {attrs: ['id']});
    const linkElement: HTMLAnchorElement|null =
        this.shadowRoot!.querySelector('#reworkFlowLink');
    assert(linkElement);
    linkElement.setAttribute('href', '#');
    linkElement.addEventListener('click', (e: Event) => {
      if (this.allButtonsDisabled) {
        return;
      }

      this.onReworkFlowLinkClicked(e);
    });
  }

  protected isComponentDisabled(componentDisabled: boolean): boolean {
    return this.allButtonsDisabled || componentDisabled;
  }

  private updateIsFirstClickableComponent(): void {
    const firstClickableComponent = this.componentCheckboxes.find(
        (component: ComponentCheckbox) => !component.disabled);
    this.componentCheckboxes.forEach(component => {
      component.isFirstClickableComponent =
          component === firstClickableComponent;
    });
  }

  getComponentRepairStateListForTesting(): Component[] {
    return this.getComponentRepairStateList();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [OnboardingSelectComponentsPageElement.is]:
        OnboardingSelectComponentsPageElement;
  }
}

customElements.define(
    OnboardingSelectComponentsPageElement.is,
    OnboardingSelectComponentsPageElement);