Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
new file mode 100644
index 0000000..741baaa
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
@@ -0,0 +1,102 @@
+// 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 {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import './mr-phase.js';
+
+/**
+ * `<mr-launch-overview>`
+ *
+ * This is a shorthand view of the phases for a user to see a quick overview.
+ *
+ */
+export class MrLaunchOverview extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <style>
+        mr-launch-overview {
+          max-width: 100%;
+          display: flex;
+          flex-flow: column;
+          justify-content: flex-start;
+          align-items: stretch;
+        }
+        mr-launch-overview[hidden] {
+          display: none;
+        }
+        mr-phase {
+          margin-bottom: 0.75em;
+        }
+      </style>
+      ${this.phases.map((phase) => html`
+        <mr-phase
+          .phaseName=${phase.phaseRef.phaseName}
+          .approvals=${this._approvalsForPhase(this.approvals, phase.phaseRef.phaseName)}
+        ></mr-phase>
+      `)}
+      ${this._phaselessApprovals.length ? html`
+        <mr-phase .approvals=${this._phaselessApprovals}></mr-phase>
+      `: ''}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      approvals: {type: Array},
+      phases: {type: Array},
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.approvals = [];
+    this.phases = [];
+    this.hidden = true;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    if (!issueV0.viewedIssue(state)) return;
+
+    this.approvals = issueV0.viewedIssue(state).approvalValues || [];
+    this.phases = issueV0.viewedIssue(state).phases || [];
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('phases') || changedProperties.has('approvals')) {
+      this.hidden = !this.phases.length && !this.approvals.length;
+    }
+    super.update(changedProperties);
+  }
+
+  get _phaselessApprovals() {
+    return this._approvalsForPhase(this.approvals);
+  }
+
+  _approvalsForPhase(approvals, phaseName) {
+    return (approvals || []).filter((a) => {
+      // We can assume phase names will be unique.
+      return a.phaseRef.phaseName == phaseName;
+    });
+  }
+}
+customElements.define('mr-launch-overview', MrLaunchOverview);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js
new file mode 100644
index 0000000..3e2ff46
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js
@@ -0,0 +1,24 @@
+// 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 {assert} from 'chai';
+import {MrLaunchOverview} from './mr-launch-overview.js';
+
+
+let element;
+
+describe('mr-launch-overview', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-launch-overview');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrLaunchOverview);
+  });
+});
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);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
new file mode 100644
index 0000000..d55897e
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
@@ -0,0 +1,209 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+
+import {MrPhase} from './mr-phase.js';
+
+
+let element;
+
+describe('mr-phase', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-phase');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrPhase);
+  });
+
+  it('clicking edit button opens edit dialog', async () => {
+    element.phaseName = 'Beta';
+
+    await element.updateComplete;
+
+    const editDialog = element.querySelector('#editPhase');
+    assert.isFalse(editDialog.opened);
+
+    element.querySelector('.phase-edit').click();
+
+    await element.updateComplete;
+
+    assert.isTrue(editDialog.opened);
+  });
+
+  it('discarding form changes closes dialog', async () => {
+    await element.updateComplete;
+
+    // Open the edit dialog.
+    element.edit();
+    const editDialog = element.querySelector('#editPhase');
+    const editForm = element.querySelector('#metadataForm');
+
+    await element.updateComplete;
+
+    assert.isTrue(editDialog.opened);
+    editForm.discard();
+
+    await element.updateComplete;
+
+    assert.isFalse(editDialog.opened);
+  });
+
+  describe('milestone fetching', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'fetchMilestoneData');
+    });
+
+    it('_launchedMilestone extracts M-Launched for phase', () => {
+      element._fieldValueMap = new Map([['m-launched beta', ['87']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._launchedMilestone, '87');
+      assert.equal(element._approvedMilestone, undefined);
+      assert.equal(element._targetMilestone, undefined);
+    });
+
+    it('_approvedMilestone extracts M-Approved for phase', () => {
+      element._fieldValueMap = new Map([['m-approved beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._launchedMilestone, undefined);
+      assert.equal(element._approvedMilestone, '86');
+      assert.equal(element._targetMilestone, undefined);
+    });
+
+    it('_targetMilestone extracts M-Target for phase', () => {
+      element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._launchedMilestone, undefined);
+      assert.equal(element._approvedMilestone, undefined);
+      assert.equal(element._targetMilestone, '85');
+    });
+
+    it('_milestoneToFetch returns empty when no relevant milestone', () => {
+      element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+      element.phaseName = 'Stable';
+
+      assert.equal(element._milestoneToFetch, '');
+    });
+
+    it('_milestoneToFetch selects highest milestone', () => {
+      element._fieldValueMap = new Map([
+        ['m-target beta', ['84']],
+        ['m-approved beta', ['85']],
+        ['m-launched beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      assert.equal(element._milestoneToFetch, '86');
+    });
+
+    it('does not fetch when no milestones specified', async () => {
+      element.issue = {projectName: 'chromium', localId: 12};
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.fetchMilestoneData);
+    });
+
+    it('does not fetch when milestone to fetch is unchanged', async () => {
+      element._fetchedMilestone = '86';
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.fetchMilestoneData);
+    });
+
+    it('fetches when milestone found', async () => {
+      element._fetchedMilestone = undefined;
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '86');
+    });
+
+    it('re-fetches when new milestone found', async () => {
+      element._fetchedMilestone = '86';
+      element._fieldValueMap = new Map([
+        ['m-target beta', ['86']],
+        ['m-launched beta', ['87']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '87');
+    });
+
+    it('re-fetches only after last stale fetch finishes', async () => {
+      element._fetchedMilestone = '84';
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+      element._isFetchingMilestone = true;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.fetchMilestoneData);
+
+      // Previous in flight fetch finishes.
+      element._fetchedMilestone = '85';
+      element._isFetchingMilestone = false;
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '86');
+    });
+  });
+
+  describe('milestone fetching with fake server responses', () => {
+    beforeEach(() => {
+      sinon.stub(window, 'fetch');
+      sinon.spy(element, 'fetchMilestoneData');
+    });
+
+    afterEach(() => {
+      window.fetch.restore();
+    });
+
+    it('does not refetch when server response finishes', async () => {
+      const response = new window.Response('{"mstones": [{"mstone": 86}]}', {
+        status: 200,
+        headers: {
+          'Content-type': 'application/json',
+        },
+      });
+
+      window.fetch.returns(Promise.resolve(response));
+
+      element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+      element.phaseName = 'Beta';
+
+      await element.updateComplete;
+
+      sinon.assert.calledWith(element.fetchMilestoneData, '86');
+
+      assert.isTrue(element._isFetchingMilestone);
+
+      await element._fetchMilestoneComplete;
+
+      assert.deepEqual(element._milestoneData, {'mstones': [{'mstone': 86}]});
+      assert.equal(element._fetchedMilestone, '86');
+      assert.isFalse(element._isFetchingMilestone);
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element.fetchMilestoneData);
+    });
+  });
+});