blob: 248c7d5d549e9421330cf603d7e974cdf9fca58f [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +02005import {LitElement, html, css} from 'lit-element';
Copybara854996b2021-09-07 19:36:02 +00006import debounce from 'debounce';
7
8import {store, connectStore} from 'reducers/base.js';
9import * as issueV0 from 'reducers/issueV0.js';
10import * as projectV0 from 'reducers/projectV0.js';
11import * as ui from 'reducers/ui.js';
12import {arrayToEnglish} from 'shared/helpers.js';
13import './mr-edit-metadata.js';
14import 'shared/typedef.js';
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020015import {migratedTypes} from 'shared/issue-fields.js';
Copybara854996b2021-09-07 19:36:02 +000016import ClientLogger from 'monitoring/client-logger.js';
17
18const DEBOUNCED_PRESUBMIT_TIME_OUT = 400;
19
20/**
21 * `<mr-edit-issue>`
22 *
23 * Edit form for a single issue. Wraps <mr-edit-metadata>.
24 *
25 */
26export class MrEditIssue extends connectStore(LitElement) {
27 /** @override */
28 render() {
29 const issue = this.issue || {};
30 let blockedOnRefs = issue.blockedOnIssueRefs || [];
31 if (issue.danglingBlockedOnRefs && issue.danglingBlockedOnRefs.length) {
32 blockedOnRefs = blockedOnRefs.concat(issue.danglingBlockedOnRefs);
33 }
34
35 let blockingRefs = issue.blockingIssueRefs || [];
36 if (issue.danglingBlockingRefs && issue.danglingBlockingRefs.length) {
37 blockingRefs = blockingRefs.concat(issue.danglingBlockingRefs);
38 }
39
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020040 let migratedNotice = html``;
41 if (this._isMigrated) {
42 migratedNotice = html`
43 <div class="migrated-banner">
44 <i
45 class="warning-icon material-icons"
46 icon="warning"
47 >warning</i>
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020048 ${this._migratedLink}
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020049 </div>
50 <chops-button
51 class="legacy-edit"
52 @click=${this._allowLegacyEdits}
53 >
54 I want to edit the old version of this issue.
55 </chops-button>
56 `;
57 }
58
Copybara854996b2021-09-07 19:36:02 +000059 return html`
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020060 <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
61 rel="stylesheet">
62 <style>
63 mr-edit-issue .migrated-banner {
64 width: 100%;
65 background-color: var(--chops-orange-50);
66 border: var(--chops-normal-border);
67 border-top: 0;
68 font-size: var(--chops-main-font-size);
69 padding: 0.25em 8px;
70 box-sizing: border-box;
71 display: flex;
72 flex-direction: row;
73 justify-content: flex-start;
74 align-items: center;
75 margin-bottom: 1em;
76 }
77 mr-edit-issue i.material-icons {
78 color: var(--chops-primary-icon-color);
79 font-size: var(--chops-icon-font-size);
80 }
81 mr-edit-issue .warning-icon {
82 margin-right: 4px;
83 }
84 mr-edit-issue .legacy-edit {
85 margin-bottom: 2em;
86 }
87 </style>
Copybara854996b2021-09-07 19:36:02 +000088 <h2 id="makechanges" class="medium-heading">
89 <a href="#makechanges">Add a comment and make changes</a>
90 </h2>
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020091
92 ${migratedNotice}
93
Copybara854996b2021-09-07 19:36:02 +000094 <mr-edit-metadata
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020095 ?hidden=${this._isMigrated && !this._editLegacyIssue}
Copybara854996b2021-09-07 19:36:02 +000096 formName="Issue Edit"
97 .ownerName=${this._ownerDisplayName(this.issue.ownerRef)}
98 .cc=${issue.ccRefs}
99 .status=${issue.statusRef && issue.statusRef.status}
100 .statuses=${this._availableStatuses(this.projectConfig.statusDefs, this.issue.statusRef)}
101 .summary=${issue.summary}
102 .components=${issue.componentRefs}
103 .fieldDefs=${this._fieldDefs}
104 .fieldValues=${issue.fieldValues}
105 .blockedOn=${blockedOnRefs}
106 .blocking=${blockingRefs}
107 .mergedInto=${issue.mergedIntoIssueRef}
108 .labelNames=${this._labelNames}
109 .derivedLabels=${this._derivedLabels}
110 .error=${this.updateError}
111 ?saving=${this.updatingIssue}
112 @save=${this.save}
113 @discard=${this.reset}
114 @change=${this._onChange}
115 ></mr-edit-metadata>
116 `;
117 }
118
119 /** @override */
120 static get properties() {
121 return {
122 /**
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200123 * ID of an Issue Tracker issue that the issue migrated to.
124 */
125 migratedId: {
126 type: String,
127 },
128 /**
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200129 * Type of the issue migrated to.
130 */
131 migratedType: {
132 type: migratedTypes,
133 },
134 /**
Copybara854996b2021-09-07 19:36:02 +0000135 * All comments, including descriptions.
136 */
137 comments: {
138 type: Array,
139 },
140 /**
141 * The issue being updated.
142 */
143 issue: {
144 type: Object,
145 },
146 /**
147 * The issueRef for the currently viewed issue.
148 */
149 issueRef: {
150 type: Object,
151 },
152 /**
153 * The config of the currently viewed project.
154 */
155 projectConfig: {
156 type: Object,
157 },
158 /**
159 * Whether the issue is currently being updated.
160 */
161 updatingIssue: {
162 type: Boolean,
163 },
164 /**
165 * An error response, if one exists.
166 */
167 updateError: {
168 type: String,
169 },
170 /**
171 * Hash from the URL, used to support the 'r' hot key for making changes.
172 */
173 focusId: {
174 type: String,
175 },
176 _fieldDefs: {
177 type: Array,
178 },
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200179 _editLegacyIssue: {
180 type: Boolean,
181 },
Copybara854996b2021-09-07 19:36:02 +0000182 };
183 }
184
185 /** @override */
186 constructor() {
187 super();
188
189 this.clientLogger = new ClientLogger('issues');
190 this.updateError = '';
191
192 this.presubmitDebounceTimeOut = DEBOUNCED_PRESUBMIT_TIME_OUT;
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200193
194 this._editLegacyIssue = false;
Copybara854996b2021-09-07 19:36:02 +0000195 }
196
197 /** @override */
198 createRenderRoot() {
199 return this;
200 }
201
202 /** @override */
203 disconnectedCallback() {
204 super.disconnectedCallback();
205
206 // Prevent debounced logic from running after the component has been
207 // removed from the UI.
208 if (this._debouncedPresubmit) {
209 this._debouncedPresubmit.clear();
210 }
211 }
212
213 /** @override */
214 stateChanged(state) {
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200215 this.migratedId = issueV0.migratedId(state);
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200216 this.migratedType = issueV0.migratedType(state);
Copybara854996b2021-09-07 19:36:02 +0000217 this.issue = issueV0.viewedIssue(state);
218 this.issueRef = issueV0.viewedIssueRef(state);
219 this.comments = issueV0.comments(state);
220 this.projectConfig = projectV0.viewedConfig(state);
221 this.updatingIssue = issueV0.requests(state).update.requesting;
222
223 const error = issueV0.requests(state).update.error;
224 this.updateError = error && (error.description || error.message);
225 this.focusId = ui.focusId(state);
226 this._fieldDefs = issueV0.fieldDefs(state);
227 }
228
229 /** @override */
230 updated(changedProperties) {
231 if (this.focusId && changedProperties.has('focusId')) {
232 // TODO(zhangtiff): Generalize logic to focus elements based on ID
233 // to a reuseable class mixin.
234 if (this.focusId.toLowerCase() === 'makechanges') {
235 this.focus();
236 }
237 }
238
239 if (changedProperties.has('updatingIssue')) {
240 const isUpdating = this.updatingIssue;
241 const wasUpdating = changedProperties.get('updatingIssue');
242
243 // When an issue finishes updating, we want to show a snackbar, record
244 // issue update time metrics, and reset the edit form.
245 if (!isUpdating && wasUpdating) {
246 if (!this.updateError) {
247 this._showCommentAddedSnackbar();
248 // Reset the edit form when a user's action finishes.
249 this.reset();
250 }
251
252 // Record metrics on when the issue editing event finished.
253 if (this.clientLogger.started('issue-update')) {
254 this.clientLogger.logEnd('issue-update', 'computer-time', 120 * 1000);
255 }
256 }
257 }
258 }
259
260 // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
261 /**
262 * Snows a snackbar telling the user they added a comment to the issue.
263 */
264 _showCommentAddedSnackbar() {
265 store.dispatch(ui.showSnackbar(ui.snackbarNames.ISSUE_COMMENT_ADDED,
266 'Your comment was added.'));
267 }
268
269 /**
270 * Resets all form fields to their initial values.
271 */
272 reset() {
273 const form = this.querySelector('mr-edit-metadata');
274 if (!form) return;
275 form.reset();
276 }
277
278 /**
279 * Dispatches an action to save issue changes on the server.
280 */
281 async save() {
282 const form = this.querySelector('mr-edit-metadata');
283 if (!form) return;
284
285 const delta = form.delta;
286 if (!allowRemovedRestrictions(delta.labelRefsRemove)) {
287 return;
288 }
289
290 const message = {
291 issueRef: this.issueRef,
292 delta: delta,
293 commentContent: form.getCommentContent(),
294 sendEmail: form.sendEmail,
295 };
296
297 // Add files to message.
298 const uploads = await form.getAttachments();
299
300 if (uploads && uploads.length) {
301 message.uploads = uploads;
302 }
303
304 if (message.commentContent || message.delta || message.uploads) {
305 this.clientLogger.logStart('issue-update', 'computer-time');
306
307 store.dispatch(issueV0.update(message));
308 }
309 }
310
311 /**
312 * Focuses the edit form in response to the 'r' hotkey.
313 */
314 focus() {
315 const editHeader = this.querySelector('#makechanges');
316 editHeader.scrollIntoView();
317
318 const editForm = this.querySelector('mr-edit-metadata');
319 editForm.focus();
320 }
321
322 /**
323 * Turns all LabelRef Objects attached to an issue into an Array of strings
324 * containing only the names of those labels that aren't derived.
325 * @return {Array<string>} Array of label names.
326 */
327 get _labelNames() {
328 if (!this.issue || !this.issue.labelRefs) return [];
329 const labels = this.issue.labelRefs;
330 return labels.filter((l) => !l.isDerived).map((l) => l.label);
331 }
332
333 /**
334 * Finds only the derived labels attached to an issue and returns only
335 * their names.
336 * @return {Array<string>} Array of label names.
337 */
338 get _derivedLabels() {
339 if (!this.issue || !this.issue.labelRefs) return [];
340 const labels = this.issue.labelRefs;
341 return labels.filter((l) => l.isDerived).map((l) => l.label);
342 }
343
344 /**
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200345 * @return {boolean} Whether this issue is migrated or not.
346 */
347 get _isMigrated() {
348 return this.migratedId && this.migratedId !== '';
349 }
350
351 /**
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200352 * @return {string} the link of the issue in Issue Tracker or Launch.
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200353 */
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200354 get _migratedLink() {
355 if (this.migratedType === migratedTypes.BUGANIZER_TYPE) {
356 const link =
357 html`<a href="https://issuetracker.google.com/issues/${this.migratedId}">b/${this.migratedId}</a>`;
358 return html`<p>This issue has moved to ${link}. Updates should be posted in ${link}.</p>`;
359 } else {
360 return html`<p>This issue has been migrated to Launch, see link in final comment below.</p>`;
361 }
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200362 }
363
364 /**
365 * Let the user override th edit form being hidden, in case of mistakes or
366 * similar.
367 */
368 _allowLegacyEdits() {
369 this._editLegacyIssue = true;
370 }
371
372 /**
Copybara854996b2021-09-07 19:36:02 +0000373 * Gets the displayName of the owner. Only uses the displayName if a
374 * userId also exists in the ref.
375 * @param {UserRef} ownerRef The owner of the issue.
376 * @return {string} The name of the owner for the edited issue.
377 */
378 _ownerDisplayName(ownerRef) {
379 return (ownerRef && ownerRef.userId) ? ownerRef.displayName : '';
380 }
381
382 /**
383 * Dispatches an action against the server to run "issue presubmit", a feature
384 * that warns the user about issue changes that violate configured rules.
385 * @param {Object=} issueDelta Changes currently present in the edit form.
386 * @param {string} commentContent Text the user is inputting for a comment.
387 */
388 _presubmitIssue(issueDelta = {}, commentContent) {
389 // Don't run this functionality if the element has disconnected. Important
390 // for preventing debounced code from running after an element no longer
391 // exists.
392 if (!this.isConnected) return;
393
394 if (Object.keys(issueDelta).length || commentContent) {
395 // TODO(crbug.com/monorail/8638): Make filter rules actually process
396 // the text for comments on the backend.
397 store.dispatch(issueV0.presubmit(this.issueRef, issueDelta));
398 }
399 }
400
401 /**
402 * Form change handler that runs presubmit on the form.
403 * @param {CustomEvent} evt
404 */
405 _onChange(evt) {
406 const {delta, commentContent} = evt.detail || {};
407
408 if (!this._debouncedPresubmit) {
409 this._debouncedPresubmit = debounce(
410 (delta, commentContent) => this._presubmitIssue(delta, commentContent),
411 this.presubmitDebounceTimeOut);
412 }
413 this._debouncedPresubmit(delta, commentContent);
414 }
415
416 /**
417 * Creates the list of statuses that the user sees in the status dropdown.
418 * @param {Array<StatusDef>} statusDefsArg The project configured StatusDefs.
419 * @param {StatusRef} currentStatusRef The status that the issue currently
420 * uses. Note that Monorail supports free text statuses that do not exist in
421 * a project config. Because of this, currentStatusRef may not exist in
422 * statusDefsArg.
423 * @return {Array<StatusRef|StatusDef>} Array of statuses a user can edit this
424 * issue to have.
425 */
426 _availableStatuses(statusDefsArg, currentStatusRef) {
427 let statusDefs = statusDefsArg || [];
428 statusDefs = statusDefs.filter((status) => !status.deprecated);
429 if (!currentStatusRef || statusDefs.find(
430 (status) => status.status === currentStatusRef.status)) {
431 return statusDefs;
432 }
433 return [currentStatusRef, ...statusDefs];
434 }
435}
436
437/**
438 * Asks the user for confirmation when they try to remove retriction labels.
439 * eg. Restrict-View-Google.
440 * @param {Array<LabelRef>} labelRefsRemoved The labels a user is removing
441 * from this issue.
442 * @return {boolean} Whether removing these labels is okay. ie: true if there
443 * are either no restrictions being removed or if the user approved the
444 * removal of the restrictions.
445 */
446export function allowRemovedRestrictions(labelRefsRemoved) {
447 if (!labelRefsRemoved) return true;
448 const removedRestrictions = labelRefsRemoved
449 .map(({label}) => label)
450 .filter((label) => label.toLowerCase().startsWith('restrict-'));
451 const removeRestrictionsMessage =
452 'You are removing these restrictions:\n' +
453 arrayToEnglish(removedRestrictions) + '\n' +
454 'This might allow more people to access this issue. Are you sure?';
455 return !removedRestrictions.length || confirm(removeRestrictionsMessage);
456}
457
458customElements.define('mr-edit-issue', MrEditIssue);