blob: 6b00608152d3541f579fe3822fe0333c7837e47e [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2019 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html} from 'lit-element';
6
7import 'elements/chops/chops-dialog/chops-dialog.js';
8import {store, connectStore} from 'reducers/base.js';
9import * as issueV0 from 'reducers/issueV0.js';
10import * as projectV0 from 'reducers/projectV0.js';
11import '../mr-approval-card/mr-approval-card.js';
12import {valueForField, valuesForField} from 'shared/metadata-helpers.js';
13import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
14import 'elements/issue-detail/metadata/mr-metadata/mr-field-values.js';
15
16const TARGET_PHASE_MILESTONE_MAP = {
17 'Beta': 'feature_freeze',
18 'Stable-Exp': 'final_beta_cut',
19 'Stable': 'stable_cut',
20 'Stable-Full': 'stable_cut',
21};
22
23const APPROVED_PHASE_MILESTONE_MAP = {
24 'Beta': 'earliest_beta',
25 'Stable-Exp': 'final_beta',
26 'Stable': 'stable_date',
27 'Stable-Full': 'stable_date',
28};
29
30// The following milestones are unique to ios.
31const IOS_APPROVED_PHASE_MILESTONE_MAP = {
32 'Beta': 'earliest_beta_ios',
33};
34
35// See monorail:4692 and the use of PHASES_WITH_MILESTONES
36// in tracker/issueentry.py
37const PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full'];
38
39/**
40 * `<mr-phase>`
41 *
42 * This is the component for a single phase.
43 *
44 */
45export class MrPhase extends connectStore(LitElement) {
46 /** @override */
47 render() {
48 const isPhaseWithMilestone = PHASES_WITH_MILESTONES.includes(
49 this.phaseName);
50 const noApprovals = !this.approvals || !this.approvals.length;
51 return html`
52 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
53 <style>
54 mr-phase {
55 display: block;
56 }
57 mr-phase chops-dialog {
58 --chops-dialog-theme: {
59 width: 500px;
60 max-width: 100%;
61 };
62 }
63 mr-phase h2 {
64 margin: 0;
65 font-size: var(--chops-large-font-size);
66 font-weight: normal;
67 padding: 0.5em 8px;
68 box-sizing: border-box;
69 display: flex;
70 align-items: center;
71 flex-direction: row;
72 justify-content: space-between;
73 }
74 mr-phase h2 em {
75 margin-left: 16px;
76 font-size: var(--chops-main-font-size);
77 }
78 mr-phase .chip {
79 display: inline-block;
80 font-size: var(--chops-main-font-size);
81 padding: 0.25em 8px;
82 margin: 0 2px;
83 border-radius: 16px;
84 background: var(--chops-blue-gray-50);
85 }
86 .phase-edit {
87 padding: 0.1em 8px;
88 }
89 </style>
90 <h2>
91 <div>
92 Approvals<span ?hidden=${!this.phaseName || !this.phaseName.length}>:
93 ${this.phaseName}
94 </span>
95 ${isPhaseWithMilestone ? html`${this.fieldDefs &&
96 this.fieldDefs.map((field) => this._renderPhaseField(field))}
97 <em ?hidden=${!this._nextDate}>
98 ${this._dateDescriptor}
99 <chops-timestamp .timestamp=${this._nextDate}></chops-timestamp>
100 </em>
101 <em ?hidden=${!this._nextUniqueiOSDate}>
102 <b>iOS</b> ${this._dateDescriptor}
103 <chops-timestamp .timestamp=${this._nextUniqueiOSDate}
104 ></chops-timestamp>
105 </em>
106 `: ''}
107 </div>
108 ${isPhaseWithMilestone ? html`
109 <chops-button @click=${this.edit} class="de-emphasized phase-edit">
110 <i class="material-icons" role="presentation">create</i>
111 Edit
112 </chops-button>
113 `: ''}
114 </h2>
115 ${this.approvals && this.approvals.map((approval) => html`
116 <mr-approval-card
117 .approvers=${approval.approverRefs}
118 .setter=${approval.setterRef}
119 .fieldName=${approval.fieldRef.fieldName}
120 .phaseName=${this.phaseName}
121 .statusEnum=${approval.status}
122 .survey=${approval.survey}
123 .surveyTemplate=${approval.surveyTemplate}
124 .urls=${approval.urls}
125 .labels=${approval.labels}
126 .users=${approval.users}
127 ></mr-approval-card>
128 `)}
129 ${noApprovals ? html`No tasks for this phase.` : ''}
130 <!-- TODO(ehmaldonado): Move to /issue-detail/dialogs -->
131 <chops-dialog id="editPhase" aria-labelledby="phaseDialogTitle">
132 <h3 id="phaseDialogTitle" class="medium-heading">
133 Editing phase: ${this.phaseName}
134 </h3>
135 <mr-edit-metadata
136 id="metadataForm"
137 class="edit-actions-right"
138 .formName=${this.phaseName}
139 .fieldDefs=${this.fieldDefs}
140 .phaseName=${this.phaseName}
141 ?disabled=${this._updatingIssue}
142 .error=${this._updateIssueError && this._updateIssueError.description}
143 @save=${this.save}
144 @discard=${this.cancel}
145 isApproval
146 disableAttachments
147 ></mr-edit-metadata>
148 </chops-dialog>
149 `;
150 }
151
152 /**
153 *
154 * @param {FieldDef} field The field to be rendered.
155 * @return {TemplateResult}
156 * @private
157 */
158 _renderPhaseField(field) {
159 const values = valuesForField(this._fieldValueMap, field.fieldRef.fieldName,
160 this.phaseName);
161 return html`
162 <div class="chip">
163 ${field.fieldRef.fieldName}:
164 <mr-field-values
165 .name=${field.fieldRef.fieldName}
166 .type=${field.fieldRef.type}
167 .values=${values}
168 .projectName=${this.issueRef.projectName}
169 ></mr-field-values>
170 </div>
171 `;
172 }
173
174 /** @override */
175 static get properties() {
176 return {
177 issue: {type: Object},
178 issueRef: {type: Object},
179 phaseName: {type: String},
180 approvals: {type: Array},
181 fieldDefs: {type: Array},
182
183 _updatingIssue: {type: Boolean},
184 _updateIssueError: {type: Object},
185 _fieldValueMap: {type: Object},
186 _milestoneData: {type: Object},
187 _isFetchingMilestone: {type: Boolean},
188 _fetchedMilestone: {type: String},
189 };
190 }
191
192 /** @override */
193 createRenderRoot() {
194 return this;
195 }
196
197 /** @override */
198 constructor() {
199 super();
200
201 this.issue = {};
202 this.issueRef = {};
203 this.phaseName = '';
204 this.approvals = [];
205 this.fieldDefs = [];
206
207 this._updatingIssue = false;
208 this._updateIssueError = undefined;
209
210 // A response Object from
211 // https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
212 this._milestoneData = {};
213 this._isFetchingMilestone = false;
214 this._fetchedMilestone = undefined;
215 /**
216 * @type {Promise} Used for testing to allow waiting for milestone
217 * fetch operations to finish.
218 */
219 this._fetchMilestoneComplete = undefined;
220 }
221
222 /** @override */
223 stateChanged(state) {
224 this.issue = issueV0.viewedIssue(state);
225 this.issueRef = issueV0.viewedIssueRef(state);
226 this.fieldDefs = projectV0.fieldDefsForPhases(state);
227 this._updatingIssue = issueV0.requests(state).update.requesting;
228 this._updateIssueError = issueV0.requests(state).update.error;
229 this._fieldValueMap = issueV0.fieldValueMap(state);
230 }
231
232 /** @override */
233 updated(changedProperties) {
234 if (changedProperties.has('issue')) {
235 this.reset();
236 }
237 if (changedProperties.has('_updatingIssue')) {
238 if (!this._updatingIssue && !this._updateIssueError) {
239 // Close phase edit modal only after a request finishes without errors.
240 this.cancel();
241 }
242 }
243
244 if (!this._isFetchingMilestone) {
245 const milestoneToFetch = this._milestoneToFetch;
246 if (milestoneToFetch && this._fetchedMilestone !== milestoneToFetch) {
247 this._fetchMilestoneComplete = this.fetchMilestoneData(
248 milestoneToFetch);
249 }
250 }
251 }
252
253 /**
254 * Makes an XHR request to Chromium Dash to find Chrome-specific launch data.
255 * eg. when certain Chrome milestones are planned for release.
256 * @param {string} milestone A string containing a Chrome milestone number.
257 * @return {Promise<void>}
258 */
259 async fetchMilestoneData(milestone) {
260 this._isFetchingMilestone = true;
261
262 try {
263 const resp = await window.fetch(
264 `https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${
265 milestone}`);
266 this._milestoneData = await resp.json();
267 } catch (error) {
268 console.error(`Error when fetching milestone data: ${error}`);
269 }
270 this._fetchedMilestone = milestone;
271 this._isFetchingMilestone = false;
272 }
273
274 /**
275 * Opens the phase editing dialog when the user clicks the edit button.
276 */
277 edit() {
278 this.reset();
279 this.querySelector('#editPhase').open();
280 }
281
282 /**
283 * Stops editing the phase.
284 */
285 cancel() {
286 this.querySelector('#editPhase').close();
287 this.reset();
288 }
289
290 /**
291 * Resets the edit form to its default values.
292 */
293 reset() {
294 const form = this.querySelector('#metadataForm');
295 form.reset();
296 }
297
298 /**
299 * Saves the changes the user has made.
300 */
301 save() {
302 const form = this.querySelector('#metadataForm');
303 const delta = form.delta;
304
305 if (delta.fieldValsAdd) {
306 delta.fieldValsAdd = delta.fieldValsAdd.map(
307 (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
308 }
309 if (delta.fieldValsRemove) {
310 delta.fieldValsRemove = delta.fieldValsRemove.map(
311 (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
312 }
313
314 const message = {
315 issueRef: this.issueRef,
316 delta: delta,
317 sendEmail: form.sendEmail,
318 commentContent: form.getCommentContent(),
319 };
320
321 if (message.commentContent || message.delta) {
322 store.dispatch(issueV0.update(message));
323 }
324 }
325
326 /**
327 * Shows the next relevant Chrome Milestone date for this phase. Depending
328 * on the M-Target, M-Approved, or M-Launched values, this date means
329 * different things.
330 * @return {number} Unix timestamp in seconds.
331 * @private
332 */
333 get _nextDate() {
334 const phaseName = this.phaseName;
335 const status = this._status;
336 let data = this._milestoneData && this._milestoneData.mstones;
337 // Data pulled from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
338 if (!phaseName || !status || !data || !data.length) return 0;
339 data = data[0];
340
341 let key = TARGET_PHASE_MILESTONE_MAP[phaseName];
342 if (['Approved', 'Launched'].includes(status)) {
343 const osValues = this._fieldValueMap.get('OS');
344 // If iOS is the only OS and the phase is one where iOS has unique
345 // milestones, the only date we show should be this._nextUniqueiOSDate.
346 if (osValues && osValues.every((os) => {
347 return os === 'iOS';
348 }) && phaseName in IOS_APPROVED_PHASE_MILESTONE_MAP) {
349 return 0;
350 }
351 key = APPROVED_PHASE_MILESTONE_MAP[phaseName];
352 }
353 if (!key || !(key in data)) return 0;
354 return Math.floor((new Date(data[key])).getTime() / 1000);
355 }
356
357 /**
358 * For issues where iOS is the OS, this function finds the relevant iOS
359 * launch date.
360 * @return {number} Unix timestamp in seconds.
361 * @private
362 */
363 get _nextUniqueiOSDate() {
364 const phaseName = this.phaseName;
365 const status = this._status;
366 let data = this._milestoneData && this._milestoneData.mstones;
367 // Data pull from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
368 if (!phaseName || !status || !data || !data.length) return 0;
369 data = data[0];
370
371 const osValues = this._fieldValueMap.get('OS');
372 if (['Approved', 'Launched'].includes(status) &&
373 osValues && osValues.includes('iOS')) {
374 const key = IOS_APPROVED_PHASE_MILESTONE_MAP[phaseName];
375 if (key) {
376 return Math.floor((new Date(data[key])).getTime() / 1000);
377 }
378 }
379 return 0;
380 }
381
382 /**
383 * Depending on what kind of date we're showing, we want to include
384 * different text to describe the date.
385 * @return {string}
386 * @private
387 */
388 get _dateDescriptor() {
389 const status = this._status;
390 if (status === 'Approved') {
391 return 'Launching on ';
392 } else if (status === 'Launched') {
393 return 'Launched on ';
394 }
395 return 'Due by ';
396 }
397
398 /**
399 * The Chrome-specific status of a gate, computed from M-Approved,
400 * M-Launched, and M-Target fields.
401 * @return {string}
402 * @private
403 */
404 get _status() {
405 const target = this._targetMilestone;
406 const approved = this._approvedMilestone;
407 const launched = this._launchedMilestone;
408 if (approved >= target) {
409 if (launched >= approved) {
410 return 'Launched';
411 }
412 return 'Approved';
413 }
414 return 'Target';
415 }
416
417 /**
418 * The Chrome Milestone that this phase was approved for.
419 * @return {string}
420 * @private
421 */
422 get _approvedMilestone() {
423 return valueForField(this._fieldValueMap, 'M-Approved', this.phaseName);
424 }
425
426 /**
427 * The Chrome Milestone that this phase was launched on.
428 * @return {string}
429 * @private
430 */
431 get _launchedMilestone() {
432 return valueForField(this._fieldValueMap, 'M-Launched', this.phaseName);
433 }
434
435 /**
436 * The Chrome Milestone that this phase is targeting.
437 * @return {string}
438 * @private
439 */
440 get _targetMilestone() {
441 return valueForField(this._fieldValueMap, 'M-Target', this.phaseName);
442 }
443
444 /**
445 * The Chrome Milestone that's used to decide what date to show the user.
446 * @return {string}
447 * @private
448 */
449 get _milestoneToFetch() {
450 const target = Number.parseInt(this._targetMilestone) || 0;
451 const approved = Number.parseInt(this._approvedMilestone) || 0;
452 const launched = Number.parseInt(this._launchedMilestone) || 0;
453
454 const latestMilestone = Math.max(target, approved, launched);
455 return latestMilestone > 0 ? `${latestMilestone}` : '';
456 }
457}
458
459
460customElements.define('mr-phase', MrPhase);