Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
new file mode 100644
index 0000000..a81be65
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
@@ -0,0 +1,460 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import '../mr-approval-card/mr-approval-card.js';
+import {valueForField, valuesForField} from 'shared/metadata-helpers.js';
+import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
+import 'elements/issue-detail/metadata/mr-metadata/mr-field-values.js';
+
+const TARGET_PHASE_MILESTONE_MAP = {
+  'Beta': 'feature_freeze',
+  'Stable-Exp': 'final_beta_cut',
+  'Stable': 'stable_cut',
+  'Stable-Full': 'stable_cut',
+};
+
+const APPROVED_PHASE_MILESTONE_MAP = {
+  'Beta': 'earliest_beta',
+  'Stable-Exp': 'final_beta',
+  'Stable': 'stable_date',
+  'Stable-Full': 'stable_date',
+};
+
+// The following milestones are unique to ios.
+const IOS_APPROVED_PHASE_MILESTONE_MAP = {
+  'Beta': 'earliest_beta_ios',
+};
+
+// See monorail:4692 and the use of PHASES_WITH_MILESTONES
+// in tracker/issueentry.py
+const PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full'];
+
+/**
+ * `<mr-phase>`
+ *
+ * This is the component for a single phase.
+ *
+ */
+export class MrPhase extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    const isPhaseWithMilestone = PHASES_WITH_MILESTONES.includes(
+        this.phaseName);
+    const noApprovals = !this.approvals || !this.approvals.length;
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <style>
+        mr-phase {
+          display: block;
+        }
+        mr-phase chops-dialog {
+          --chops-dialog-theme: {
+            width: 500px;
+            max-width: 100%;
+          };
+        }
+        mr-phase h2 {
+          margin: 0;
+          font-size: var(--chops-large-font-size);
+          font-weight: normal;
+          padding: 0.5em 8px;
+          box-sizing: border-box;
+          display: flex;
+          align-items: center;
+          flex-direction: row;
+          justify-content: space-between;
+        }
+        mr-phase h2 em {
+          margin-left: 16px;
+          font-size: var(--chops-main-font-size);
+        }
+        mr-phase .chip {
+          display: inline-block;
+          font-size: var(--chops-main-font-size);
+          padding: 0.25em 8px;
+          margin: 0 2px;
+          border-radius: 16px;
+          background: var(--chops-blue-gray-50);
+        }
+        .phase-edit {
+          padding: 0.1em 8px;
+        }
+      </style>
+      <h2>
+        <div>
+          Approvals<span ?hidden=${!this.phaseName || !this.phaseName.length}>:
+            ${this.phaseName}
+          </span>
+          ${isPhaseWithMilestone ? html`${this.fieldDefs &&
+              this.fieldDefs.map((field) => this._renderPhaseField(field))}
+            <em ?hidden=${!this._nextDate}>
+              ${this._dateDescriptor}
+              <chops-timestamp .timestamp=${this._nextDate}></chops-timestamp>
+            </em>
+            <em ?hidden=${!this._nextUniqueiOSDate}>
+              <b>iOS</b> ${this._dateDescriptor}
+              <chops-timestamp .timestamp=${this._nextUniqueiOSDate}
+              ></chops-timestamp>
+            </em>
+          `: ''}
+        </div>
+        ${isPhaseWithMilestone ? html`
+          <chops-button @click=${this.edit} class="de-emphasized phase-edit">
+            <i class="material-icons" role="presentation">create</i>
+            Edit
+          </chops-button>
+        `: ''}
+      </h2>
+      ${this.approvals && this.approvals.map((approval) => html`
+        <mr-approval-card
+          .approvers=${approval.approverRefs}
+          .setter=${approval.setterRef}
+          .fieldName=${approval.fieldRef.fieldName}
+          .phaseName=${this.phaseName}
+          .statusEnum=${approval.status}
+          .survey=${approval.survey}
+          .surveyTemplate=${approval.surveyTemplate}
+          .urls=${approval.urls}
+          .labels=${approval.labels}
+          .users=${approval.users}
+        ></mr-approval-card>
+      `)}
+      ${noApprovals ? html`No tasks for this phase.` : ''}
+      <!-- TODO(ehmaldonado): Move to /issue-detail/dialogs -->
+      <chops-dialog id="editPhase" aria-labelledby="phaseDialogTitle">
+        <h3 id="phaseDialogTitle" class="medium-heading">
+          Editing phase: ${this.phaseName}
+        </h3>
+        <mr-edit-metadata
+          id="metadataForm"
+          class="edit-actions-right"
+          .formName=${this.phaseName}
+          .fieldDefs=${this.fieldDefs}
+          .phaseName=${this.phaseName}
+          ?disabled=${this._updatingIssue}
+          .error=${this._updateIssueError && this._updateIssueError.description}
+          @save=${this.save}
+          @discard=${this.cancel}
+          isApproval
+          disableAttachments
+        ></mr-edit-metadata>
+      </chops-dialog>
+    `;
+  }
+
+  /**
+   *
+   * @param {FieldDef} field The field to be rendered.
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderPhaseField(field) {
+    const values = valuesForField(this._fieldValueMap, field.fieldRef.fieldName,
+        this.phaseName);
+    return html`
+      <div class="chip">
+        ${field.fieldRef.fieldName}:
+        <mr-field-values
+          .name=${field.fieldRef.fieldName}
+          .type=${field.fieldRef.type}
+          .values=${values}
+          .projectName=${this.issueRef.projectName}
+        ></mr-field-values>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+      issueRef: {type: Object},
+      phaseName: {type: String},
+      approvals: {type: Array},
+      fieldDefs: {type: Array},
+
+      _updatingIssue: {type: Boolean},
+      _updateIssueError: {type: Object},
+      _fieldValueMap: {type: Object},
+      _milestoneData: {type: Object},
+      _isFetchingMilestone: {type: Boolean},
+      _fetchedMilestone: {type: String},
+    };
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.issue = {};
+    this.issueRef = {};
+    this.phaseName = '';
+    this.approvals = [];
+    this.fieldDefs = [];
+
+    this._updatingIssue = false;
+    this._updateIssueError = undefined;
+
+    // A response Object from
+    // https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+    this._milestoneData = {};
+    this._isFetchingMilestone = false;
+    this._fetchedMilestone = undefined;
+    /**
+     * @type {Promise} Used for testing to allow waiting for milestone
+     *   fetch operations to finish.
+     */
+    this._fetchMilestoneComplete = undefined;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issue = issueV0.viewedIssue(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.fieldDefs = projectV0.fieldDefsForPhases(state);
+    this._updatingIssue = issueV0.requests(state).update.requesting;
+    this._updateIssueError = issueV0.requests(state).update.error;
+    this._fieldValueMap = issueV0.fieldValueMap(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('issue')) {
+      this.reset();
+    }
+    if (changedProperties.has('_updatingIssue')) {
+      if (!this._updatingIssue && !this._updateIssueError) {
+        // Close phase edit modal only after a request finishes without errors.
+        this.cancel();
+      }
+    }
+
+    if (!this._isFetchingMilestone) {
+      const milestoneToFetch = this._milestoneToFetch;
+      if (milestoneToFetch && this._fetchedMilestone !== milestoneToFetch) {
+        this._fetchMilestoneComplete = this.fetchMilestoneData(
+            milestoneToFetch);
+      }
+    }
+  }
+
+  /**
+   * Makes an XHR request to Chromium Dash to find Chrome-specific launch data.
+   * eg. when certain Chrome milestones are planned for release.
+   * @param {string} milestone A string containing a Chrome milestone number.
+   * @return {Promise<void>}
+   */
+  async fetchMilestoneData(milestone) {
+    this._isFetchingMilestone = true;
+
+    try {
+      const resp = await window.fetch(
+          `https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${
+            milestone}`);
+      this._milestoneData = await resp.json();
+    } catch (error) {
+      console.error(`Error when fetching milestone data: ${error}`);
+    }
+    this._fetchedMilestone = milestone;
+    this._isFetchingMilestone = false;
+  }
+
+  /**
+   * Opens the phase editing dialog when the user clicks the edit button.
+   */
+  edit() {
+    this.reset();
+    this.querySelector('#editPhase').open();
+  }
+
+  /**
+   * Stops editing the phase.
+   */
+  cancel() {
+    this.querySelector('#editPhase').close();
+    this.reset();
+  }
+
+  /**
+   * Resets the edit form to its default values.
+   */
+  reset() {
+    const form = this.querySelector('#metadataForm');
+    form.reset();
+  }
+
+  /**
+   * Saves the changes the user has made.
+   */
+  save() {
+    const form = this.querySelector('#metadataForm');
+    const delta = form.delta;
+
+    if (delta.fieldValsAdd) {
+      delta.fieldValsAdd = delta.fieldValsAdd.map(
+          (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+    }
+    if (delta.fieldValsRemove) {
+      delta.fieldValsRemove = delta.fieldValsRemove.map(
+          (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+    }
+
+    const message = {
+      issueRef: this.issueRef,
+      delta: delta,
+      sendEmail: form.sendEmail,
+      commentContent: form.getCommentContent(),
+    };
+
+    if (message.commentContent || message.delta) {
+      store.dispatch(issueV0.update(message));
+    }
+  }
+
+  /**
+   * Shows the next relevant Chrome Milestone date for this phase. Depending
+   * on the M-Target, M-Approved, or M-Launched values, this date means
+   * different things.
+   * @return {number} Unix timestamp in seconds.
+   * @private
+   */
+  get _nextDate() {
+    const phaseName = this.phaseName;
+    const status = this._status;
+    let data = this._milestoneData && this._milestoneData.mstones;
+    // Data pulled from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+    if (!phaseName || !status || !data || !data.length) return 0;
+    data = data[0];
+
+    let key = TARGET_PHASE_MILESTONE_MAP[phaseName];
+    if (['Approved', 'Launched'].includes(status)) {
+      const osValues = this._fieldValueMap.get('OS');
+      // If iOS is the only OS and the phase is one where iOS has unique
+      // milestones, the only date we show should be this._nextUniqueiOSDate.
+      if (osValues && osValues.every((os) => {
+        return os === 'iOS';
+      }) && phaseName in IOS_APPROVED_PHASE_MILESTONE_MAP) {
+        return 0;
+      }
+      key = APPROVED_PHASE_MILESTONE_MAP[phaseName];
+    }
+    if (!key || !(key in data)) return 0;
+    return Math.floor((new Date(data[key])).getTime() / 1000);
+  }
+
+  /**
+   * For issues where iOS is the OS, this function finds the relevant iOS
+   * launch date.
+   * @return {number} Unix timestamp in seconds.
+   * @private
+   */
+  get _nextUniqueiOSDate() {
+    const phaseName = this.phaseName;
+    const status = this._status;
+    let data = this._milestoneData && this._milestoneData.mstones;
+    // Data pull from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+    if (!phaseName || !status || !data || !data.length) return 0;
+    data = data[0];
+
+    const osValues = this._fieldValueMap.get('OS');
+    if (['Approved', 'Launched'].includes(status) &&
+        osValues && osValues.includes('iOS')) {
+      const key = IOS_APPROVED_PHASE_MILESTONE_MAP[phaseName];
+      if (key) {
+        return Math.floor((new Date(data[key])).getTime() / 1000);
+      }
+    }
+    return 0;
+  }
+
+  /**
+   * Depending on what kind of date we're showing, we want to include
+   * different text to describe the date.
+   * @return {string}
+   * @private
+   */
+  get _dateDescriptor() {
+    const status = this._status;
+    if (status === 'Approved') {
+      return 'Launching on ';
+    } else if (status === 'Launched') {
+      return 'Launched on ';
+    }
+    return 'Due by ';
+  }
+
+  /**
+   * The Chrome-specific status of a gate, computed from M-Approved,
+   * M-Launched, and M-Target fields.
+   * @return {string}
+   * @private
+   */
+  get _status() {
+    const target = this._targetMilestone;
+    const approved = this._approvedMilestone;
+    const launched = this._launchedMilestone;
+    if (approved >= target) {
+      if (launched >= approved) {
+        return 'Launched';
+      }
+      return 'Approved';
+    }
+    return 'Target';
+  }
+
+  /**
+   * The Chrome Milestone that this phase was approved for.
+   * @return {string}
+   * @private
+   */
+  get _approvedMilestone() {
+    return valueForField(this._fieldValueMap, 'M-Approved', this.phaseName);
+  }
+
+  /**
+   * The Chrome Milestone that this phase was launched on.
+   * @return {string}
+   * @private
+   */
+  get _launchedMilestone() {
+    return valueForField(this._fieldValueMap, 'M-Launched', this.phaseName);
+  }
+
+  /**
+   * The Chrome Milestone that this phase is targeting.
+   * @return {string}
+   * @private
+   */
+  get _targetMilestone() {
+    return valueForField(this._fieldValueMap, 'M-Target', this.phaseName);
+  }
+
+  /**
+   * The Chrome Milestone that's used to decide what date to show the user.
+   * @return {string}
+   * @private
+   */
+  get _milestoneToFetch() {
+    const target = Number.parseInt(this._targetMilestone) || 0;
+    const approved = Number.parseInt(this._approvedMilestone) || 0;
+    const launched = Number.parseInt(this._launchedMilestone) || 0;
+
+    const latestMilestone = Math.max(target, approved, launched);
+    return latestMilestone > 0 ? `${latestMilestone}` : '';
+  }
+}
+
+
+customElements.define('mr-phase', MrPhase);