chromium/components/policy/resources/webui/test/policy_test_row.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 '../strings.m.js';

import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';

import type {PolicyInfo, PolicySchema, PresetAtrributes} from './policy_test_browser_proxy.js';
import {LevelNamesToValues, PolicyLevel, PolicyScope, PolicySource, Presets, ScopeNamesToValues, SourceNamesToValues} from './policy_test_browser_proxy.js';
import {getTemplate} from './policy_test_row.html.js';

export class PolicyTestRowElement extends CustomElement {
  private hasAnError_: boolean = false;
  private errorEvents_: EventTracker = new EventTracker();
  private inputType_: string|number|boolean|any[]|object;
  private schema_?: PolicySchema;

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

  constructor() {
    super();
  }

  getErrorState(): boolean {
    return this.hasAnError_;
  }

  setSchema(schema: PolicySchema) {
    const hadSchema = !!this.schema_;
    this.schema_ = schema;
    if (hadSchema) {
      this.updatePolicyNamespaces_();
      this.updatePolicyNames();
    } else {
      this.initialize_();
    }
  }

  // Event listener function that clears the policy name when the namespace is
  // changed.
  private onPolicyNamespaceChanged_(_: Event) {
    this.updatePolicyNames();
    const nameInput = this.getRequiredElement<HTMLInputElement>('.name');
    nameInput.value = '';
    this.changeInputType_(
        this.getRequiredElement<HTMLSelectElement>('.namespace'), nameInput,
        false);
  }

  // Event listener function for changing the input type when a policy name is
  // selected.
  private onPolicyNameChanged_(_: Event) {
    this.changeInputType_(
        this.getRequiredElement<HTMLSelectElement>('.namespace'),
        this.getRequiredElement<HTMLInputElement>('.name'));
  }

  private changeInputType_(
      namespaceInput: HTMLSelectElement, nameInput: HTMLInputElement,
      updateErrorState: boolean = true) {
    assert(this.schema_);
    assert(namespaceInput.value in this.schema_);
    // Return if invalid policy name
    if (!this.isValidPolicyName_(namespaceInput.value, nameInput.value)) {
      if (updateErrorState) {
        this.setInErrorState_(nameInput);
      }
      return;
    }
    const ns = this.getNamespace();
    const newValueType = this.schema_[ns]![nameInput.value];
    const inputElement = this.getRequiredElement<HTMLInputElement>('.value');
    const inputElementCell = inputElement.parentNode! as HTMLElement;
    const previousValue = inputElement.value;
    const previousValueType = this.inputType_;
    inputElement.remove();
    switch (newValueType) {
      case 'boolean':
        this.inputType_ = Boolean;
        const boolDropdown = document.createElement('select');
        boolDropdown.classList.add('value');
        // By default, have labels true/false for boolean policies, but use
        // enable/disable or allow/disallow if the policy name contains 'enable'
        // or 'allow' respectively.
        const boolOptions = {
          'true': ['True', 'Enabled', 'Allow'],
          'false': ['False', 'Disabled', 'Disallow'],
        };
        let boolOptionIndex = 0;
        const policyNameLower = nameInput.value.toLowerCase();
        if (policyNameLower.includes('enable')) {
          boolOptionIndex = 1;
        } else if (policyNameLower.includes('allow')) {
          boolOptionIndex = 2;
        }
        const optionsArray: string[] = [
          boolOptions['true'][boolOptionIndex]!,
          boolOptions['false'][boolOptionIndex]!,
        ];
        optionsArray.forEach((option: string) => {
          const optionElement = document.createElement('option');
          optionElement.textContent = option;
          optionElement.value = String(boolOptions['true'].includes(option));
          boolDropdown.appendChild(optionElement);
        });
        inputElementCell.appendChild(boolDropdown);
        break;
      case 'integer':
        this.inputType_ = Number;
        const intInput = document.createElement('input');
        intInput.type = 'number';
        intInput.classList.add('value');
        inputElementCell.appendChild(intInput);
        break;
      case 'number':
        this.inputType_ = Number;
        const numInput = document.createElement('input');
        numInput.type = 'number';
        numInput.step = 'any';
        numInput.classList.add('value');
        inputElementCell.appendChild(numInput);
        break;
      case 'string':
        this.inputType_ = String;
        const strInput = document.createElement('input');
        strInput.type = 'text';
        strInput.classList.add('value');
        inputElementCell.appendChild(strInput);
        break;
      case 'list':
        this.inputType_ = Array;
        const listInput = document.createElement('input');
        listInput.type = 'text';
        listInput.classList.add('value');
        inputElementCell.appendChild(listInput);
        break;
      case 'dictionary':
        this.inputType_ = Object;
        const dictInput = document.createElement('input');
        dictInput.type = 'text';
        dictInput.classList.add('value');
        inputElementCell.appendChild(dictInput);
        break;
      default:
        assertNotReached();
    }
    if (previousValueType === this.inputType_) {
      this.getRequiredElement<HTMLInputElement>('.value')!.value =
          previousValue;
    }
  }

  // Helper method for disabling/enabling the source, scope and level dropdowns
  // if disabled is true/false and setting their values to those given in
  // presetAttributes.
  private changeAttributesFromPreset_(
      disabled: boolean, presetAttributes?: PresetAtrributes) {
    ['.source', '.scope', '.level'].forEach((attributeSelector: string) => {
      const attributeDropdown =
          this.getRequiredElement<HTMLSelectElement>(attributeSelector);
      attributeDropdown.disabled = disabled;
      if (presetAttributes) {
        attributeDropdown.value = String(Object.values(
            presetAttributes)[Object.keys(presetAttributes)
                                  .indexOf(attributeSelector.substring(1))]);
      }
    });
  }

  // Event listener method for changing the selected values in the source, scope
  // and level dropdowns when the user selects a preset.
  private changePreset_(event: Event) {
    const selectElement = event.target! as HTMLSelectElement;
    const presetToApply = parseInt(selectElement.value);
    switch (presetToApply) {
      case Presets.PRESET_CUSTOM:
        this.changeAttributesFromPreset_(false);
        break;
      case Presets.PRESET_CBCM:
        this.changeAttributesFromPreset_(true, {
          source: PolicySource.SOURCE_CLOUD_VAL,
          scope: PolicyScope.SCOPE_DEVICE_VAL,
          level: PolicyLevel.LEVEL_MANDATORY_VAL,
        });
        break;
      case Presets.PRESET_LOCAL_MACHINE:
        this.changeAttributesFromPreset_(true, {
          source: PolicySource.SOURCE_PLATFORM_VAL,
          scope: PolicyScope.SCOPE_DEVICE_VAL,
          level: PolicyLevel.LEVEL_MANDATORY_VAL,
        });
        break;
      case Presets.PRESET_CLOUD_ACCOUNT:
        this.changeAttributesFromPreset_(true, {
          source: PolicySource.SOURCE_CLOUD_VAL,
          scope: PolicyScope.SCOPE_USER_VAL,
          level: PolicyLevel.LEVEL_MANDATORY_VAL,
        });
        break;
      default:
        assertNotReached();
    }
  }

  // Function that verifies policy name is a valid.
  private isValidPolicyName_(policyNamespace: string, policyName: string) {
    if (this.schema_ && policyNamespace in this.schema_ &&
        policyName in this.schema_[policyNamespace]!) {
      return true;
    } else {
      return false;
    }
  }

  getNamespace() {
    return this.getRequiredElement<HTMLSelectElement>('.namespace').value;
  }

  // Updates the policy namespaces <select> to match extensions from the schema.
  private updatePolicyNamespaces_() {
    assert(this.schema_);
    const policyNamespaceInput =
        this.getRequiredElement<HTMLSelectElement>('.namespace');
    assert(policyNamespaceInput.value in this.schema_);
    // Clear old values.
    while (policyNamespaceInput.firstChild) {
      policyNamespaceInput.removeChild(policyNamespaceInput.firstChild);
    }
    // Populate with extension IDs, keep sorted. 'Chrome' is always the first
    // element for browser policies.
    const sortedNamespacesWithChromeFirst =
        Object.keys(this.schema_).toSorted((a, b) => {
          if (a === 'chrome') {
            return -1;
          }
          if (b === 'chrome') {
            return 1;
          }
          return a < b ? -1 : (b < a ? 1 : 0);
        });
    for (const ns of sortedNamespacesWithChromeFirst) {
      const currOption = document.createElement('option');
      currOption.textContent = ns === 'chrome' ? 'Chrome' : ns;
      currOption.value = ns;
      policyNamespaceInput.appendChild(currOption);
    }
  }

  // Updates the policy names <datalist> to match the current namespace.
  updatePolicyNames() {
    assert(this.schema_);
    const ns = this.getNamespace();
    const policyNameDatalist = this.getRequiredElement('#policy-name-list');
    // Clear old values.
    while (policyNameDatalist.firstChild) {
      policyNameDatalist.removeChild(policyNameDatalist.firstChild);
    }
    // Populate with new values.
    for (const name in this.schema_[ns]!) {
      const currOption = document.createElement('option');
      currOption.textContent = name;
      policyNameDatalist.appendChild(currOption);
    }
    const policyNameInput = this.getRequiredElement<HTMLInputElement>('.name');
    if (policyNameInput.value) {
      this.changeInputType_(
          this.getRequiredElement('.namespace'), policyNameInput);
    }
  }

  // Function that initializes the policy selection dropdowns and delete
  // button for the current row.
  private initialize_() {
    assert(this.schema_);
    const policyNamespaceInput = this.getRequiredElement('.namespace');
    policyNamespaceInput.addEventListener(
        'change', this.onPolicyNamespaceChanged_.bind(this));

    const policyNameInput = this.getRequiredElement('.name');
    policyNameInput.addEventListener(
        'change', this.onPolicyNameChanged_.bind(this));

    // Populate the policy namespace & name dropdowns with <option>s.
    this.updatePolicyNamespaces_();
    this.updatePolicyNames();

    // Add an event listener for this row's delete button.
    this.getRequiredElement('.remove-btn')
        .addEventListener('click', this.remove.bind(this));

    // Add event listeners for this row's preset select options.
    const policyPresetDropdown =
        this.getRequiredElement<HTMLSelectElement>('.preset');
    policyPresetDropdown.addEventListener(
        'change', this.changePreset_.bind(this));

    // Set the value attributes of the policy type and preset dropdown options.
    const idToValue = [
      {id: 'scopeUser', value: PolicyScope.SCOPE_USER_VAL},
      {id: 'scopeDevice', value: PolicyScope.SCOPE_DEVICE_VAL},
      {id: 'levelRecommended', value: PolicyLevel.LEVEL_RECOMMENDED_VAL},
      {id: 'levelMandatory', value: PolicyLevel.LEVEL_MANDATORY_VAL},
      {
        id: 'sourceEnterpriseDefault',
        value: PolicySource.SOURCE_ENTERPRISE_DEFAULT_VAL,
      },
      {id: 'sourceCommandLine', value: PolicySource.SOURCE_COMMAND_LINE_VAL},
      {id: 'sourceCloud', value: PolicySource.SOURCE_CLOUD_VAL},
      // <if expr="is_chromeos">
      {
        id: 'sourceActiveDirectory',
        value: PolicySource.SOURCE_ACTIVE_DIRECTORY_VAL,
      },
      // </if>
      {id: 'sourcePlatform', value: PolicySource.SOURCE_PLATFORM_VAL},
      {id: 'sourceMerged', value: PolicySource.SOURCE_MERGED_VAL},
      // <if expr="is_chromeos">
      {id: 'sourceCloudFromAsh', value: PolicySource.SOURCE_CLOUD_FROM_ASH_VAL},
      {
        id: 'sourceRestrictedManagedGuestSessionOverride',
        value:
            PolicySource.SOURCE_RESTRICTED_MANAGED_GUEST_SESSION_OVERRIDE_VAL,
      },
      // </if>
      {id: 'custom', value: Presets.PRESET_CUSTOM},
      {id: 'cbcm', value: Presets.PRESET_CBCM},
      {id: 'localMachine', value: Presets.PRESET_LOCAL_MACHINE},
      {id: 'cloudAccount', value: Presets.PRESET_CLOUD_ACCOUNT},
    ];

    for (const pair of idToValue) {
      this.getRequiredElement(`#${pair.id}`)
          .setAttribute('value', String(pair.value));
    }
  }

  // Class method for setting the name, source, scope, level and value cells for
  // this row.
  setInitialValues(initialValues: PolicyInfo) {
    const policyNamespaceInput =
        this.getRequiredElement<HTMLSelectElement>('.namespace');
    const policyNameInput = this.getRequiredElement<HTMLInputElement>('.name');
    const policySourceInput =
        this.getRequiredElement<HTMLInputElement>('.source');
    const policyLevelInput =
        this.getRequiredElement<HTMLInputElement>('.level');
    const policyScopeInput =
        this.getRequiredElement<HTMLInputElement>('.scope');

    policyNamespaceInput.value = String(initialValues.namespace);
    this.updatePolicyNames();

    policySourceInput.value = String(initialValues.source);
    policyLevelInput.value = String(initialValues.level);
    policyScopeInput.value = String(initialValues.scope);

    // Change input type according to policy, set value to new input
    policyNameInput.value = initialValues.name;
    this.changeInputType_(policyNamespaceInput, policyNameInput);

    const policyValueInput =
        this.getRequiredElement<HTMLInputElement>('.value');
    if (this.inputType_ === String) {
      policyValueInput.value = initialValues.value;
    } else {
      policyValueInput.value = JSON.stringify(initialValues.value);
    }
  }

  // Event listener function for setting the select element background back to
  // white after being highlighted in red, and then clicked by the user.
  private resetErrorState_(event: Event) {
    (event.target! as HTMLElement).classList.remove('error');
    this.errorEvents_.remove(event.target!);
    this.hasAnError_ = false;
  }
  // Helper method for highlighting an element in red and adding an event
  // listener to get rid of the element highlight on focus, for elements with
  // invalid input.
  private setInErrorState_(inputElement: HTMLElement) {
    inputElement.classList.add('error');
    this.errorEvents_.add(
        inputElement, 'focus', this.resetErrorState_.bind(this));
    this.hasAnError_ = true;
  }

  // Class method for returning the value for this policy (the value in the
  // value cell of this row).
  getPolicyValue(): string|number|boolean|any[]|object {
    const inputElement = this.getRequiredElement<HTMLInputElement>('.value');
    // If the policy expects a string, any input is valid.
    if (this.inputType_ === String) {
      return inputElement.value.toString();
    }
    try {
      const obj = JSON.parse(inputElement.value);
      if (obj !== undefined && obj.constructor === this.inputType_) {
        return obj;
      }
      throw new Error();
    } catch {
      this.setInErrorState_(inputElement);
    }
    return '';
  }

  getPolicyNamespace(): string {
    assert(this.schema_);
    const namespaceInput =
        this.getRequiredElement<HTMLSelectElement>('.namespace');

    if (namespaceInput.value in this.schema_) {
      return namespaceInput.value;
    } else {
      this.setInErrorState_(namespaceInput);
      return '';
    }
  }

  // Class method for returning the name for this policy (the value in the
  // name cell of this row)
  getPolicyName(): string {
    const namespaceInput =
        this.getRequiredElement<HTMLSelectElement>('.namespace');
    const nameInput = this.getRequiredElement<HTMLInputElement>('.name');

    if (this.isValidPolicyName_(namespaceInput.value, nameInput.value)) {
      return nameInput.value;
    } else {
      this.setInErrorState_(nameInput);
      return '';
    }
  }

  // Class method for returning the level, source or scope set in this
  // row.
  getPolicyAttribute(attributeName: string): string {
    return this.getRequiredElement<HTMLSelectElement>(`.${attributeName}`)
        .value;
  }

  // Class method for returning the string value of the given attribute in this
  // row. Should only be used for enum attributes (level, scope and source).
  getStringPolicyAttribute(attributeName: string): string|undefined {
    const intVal: number =
        parseInt(String(this.getPolicyAttribute(`${attributeName}`)));
    switch (attributeName) {
      case 'level':
        return Object.keys(LevelNamesToValues)
            .find(name => LevelNamesToValues[name] === intVal);
      case 'scope':
        return Object.keys(ScopeNamesToValues)
            .find(name => ScopeNamesToValues[name] === intVal);
      case 'source':
        return Object.keys(SourceNamesToValues)
            .find(name => SourceNamesToValues[name] === intVal);
      default:
        assertNotReached();
    }
  }
}

// Declare the custom element.
declare global {
  interface HTMLElementTagNameMap {
    'policy-test-row': PolicyTestRowElement;
  }
}
customElements.define('policy-test-row', PolicyTestRowElement);