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