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);
+ });
+ });
+});