| // 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); |