blob: 6b00608152d3541f579fe3822fe0333c7837e47e [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// 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);