blob: 69ef43ff1c5492012ba3c6105251e4fc8ccaa828 [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
5import {LitElement, html} from 'lit-element';
6import 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
40 return html`
41 <h2 id="makechanges" class="medium-heading">
42 <a href="#makechanges">Add a comment and make changes</a>
43 </h2>
44 <mr-edit-metadata
45 formName="Issue Edit"
46 .ownerName=${this._ownerDisplayName(this.issue.ownerRef)}
47 .cc=${issue.ccRefs}
48 .status=${issue.statusRef && issue.statusRef.status}
49 .statuses=${this._availableStatuses(this.projectConfig.statusDefs, this.issue.statusRef)}
50 .summary=${issue.summary}
51 .components=${issue.componentRefs}
52 .fieldDefs=${this._fieldDefs}
53 .fieldValues=${issue.fieldValues}
54 .blockedOn=${blockedOnRefs}
55 .blocking=${blockingRefs}
56 .mergedInto=${issue.mergedIntoIssueRef}
57 .labelNames=${this._labelNames}
58 .derivedLabels=${this._derivedLabels}
59 .error=${this.updateError}
60 ?saving=${this.updatingIssue}
61 @save=${this.save}
62 @discard=${this.reset}
63 @change=${this._onChange}
64 ></mr-edit-metadata>
65 `;
66 }
67
68 /** @override */
69 static get properties() {
70 return {
71 /**
72 * All comments, including descriptions.
73 */
74 comments: {
75 type: Array,
76 },
77 /**
78 * The issue being updated.
79 */
80 issue: {
81 type: Object,
82 },
83 /**
84 * The issueRef for the currently viewed issue.
85 */
86 issueRef: {
87 type: Object,
88 },
89 /**
90 * The config of the currently viewed project.
91 */
92 projectConfig: {
93 type: Object,
94 },
95 /**
96 * Whether the issue is currently being updated.
97 */
98 updatingIssue: {
99 type: Boolean,
100 },
101 /**
102 * An error response, if one exists.
103 */
104 updateError: {
105 type: String,
106 },
107 /**
108 * Hash from the URL, used to support the 'r' hot key for making changes.
109 */
110 focusId: {
111 type: String,
112 },
113 _fieldDefs: {
114 type: Array,
115 },
116 };
117 }
118
119 /** @override */
120 constructor() {
121 super();
122
123 this.clientLogger = new ClientLogger('issues');
124 this.updateError = '';
125
126 this.presubmitDebounceTimeOut = DEBOUNCED_PRESUBMIT_TIME_OUT;
127 }
128
129 /** @override */
130 createRenderRoot() {
131 return this;
132 }
133
134 /** @override */
135 disconnectedCallback() {
136 super.disconnectedCallback();
137
138 // Prevent debounced logic from running after the component has been
139 // removed from the UI.
140 if (this._debouncedPresubmit) {
141 this._debouncedPresubmit.clear();
142 }
143 }
144
145 /** @override */
146 stateChanged(state) {
147 this.issue = issueV0.viewedIssue(state);
148 this.issueRef = issueV0.viewedIssueRef(state);
149 this.comments = issueV0.comments(state);
150 this.projectConfig = projectV0.viewedConfig(state);
151 this.updatingIssue = issueV0.requests(state).update.requesting;
152
153 const error = issueV0.requests(state).update.error;
154 this.updateError = error && (error.description || error.message);
155 this.focusId = ui.focusId(state);
156 this._fieldDefs = issueV0.fieldDefs(state);
157 }
158
159 /** @override */
160 updated(changedProperties) {
161 if (this.focusId && changedProperties.has('focusId')) {
162 // TODO(zhangtiff): Generalize logic to focus elements based on ID
163 // to a reuseable class mixin.
164 if (this.focusId.toLowerCase() === 'makechanges') {
165 this.focus();
166 }
167 }
168
169 if (changedProperties.has('updatingIssue')) {
170 const isUpdating = this.updatingIssue;
171 const wasUpdating = changedProperties.get('updatingIssue');
172
173 // When an issue finishes updating, we want to show a snackbar, record
174 // issue update time metrics, and reset the edit form.
175 if (!isUpdating && wasUpdating) {
176 if (!this.updateError) {
177 this._showCommentAddedSnackbar();
178 // Reset the edit form when a user's action finishes.
179 this.reset();
180 }
181
182 // Record metrics on when the issue editing event finished.
183 if (this.clientLogger.started('issue-update')) {
184 this.clientLogger.logEnd('issue-update', 'computer-time', 120 * 1000);
185 }
186 }
187 }
188 }
189
190 // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
191 /**
192 * Snows a snackbar telling the user they added a comment to the issue.
193 */
194 _showCommentAddedSnackbar() {
195 store.dispatch(ui.showSnackbar(ui.snackbarNames.ISSUE_COMMENT_ADDED,
196 'Your comment was added.'));
197 }
198
199 /**
200 * Resets all form fields to their initial values.
201 */
202 reset() {
203 const form = this.querySelector('mr-edit-metadata');
204 if (!form) return;
205 form.reset();
206 }
207
208 /**
209 * Dispatches an action to save issue changes on the server.
210 */
211 async save() {
212 const form = this.querySelector('mr-edit-metadata');
213 if (!form) return;
214
215 const delta = form.delta;
216 if (!allowRemovedRestrictions(delta.labelRefsRemove)) {
217 return;
218 }
219
220 const message = {
221 issueRef: this.issueRef,
222 delta: delta,
223 commentContent: form.getCommentContent(),
224 sendEmail: form.sendEmail,
225 };
226
227 // Add files to message.
228 const uploads = await form.getAttachments();
229
230 if (uploads && uploads.length) {
231 message.uploads = uploads;
232 }
233
234 if (message.commentContent || message.delta || message.uploads) {
235 this.clientLogger.logStart('issue-update', 'computer-time');
236
237 store.dispatch(issueV0.update(message));
238 }
239 }
240
241 /**
242 * Focuses the edit form in response to the 'r' hotkey.
243 */
244 focus() {
245 const editHeader = this.querySelector('#makechanges');
246 editHeader.scrollIntoView();
247
248 const editForm = this.querySelector('mr-edit-metadata');
249 editForm.focus();
250 }
251
252 /**
253 * Turns all LabelRef Objects attached to an issue into an Array of strings
254 * containing only the names of those labels that aren't derived.
255 * @return {Array<string>} Array of label names.
256 */
257 get _labelNames() {
258 if (!this.issue || !this.issue.labelRefs) return [];
259 const labels = this.issue.labelRefs;
260 return labels.filter((l) => !l.isDerived).map((l) => l.label);
261 }
262
263 /**
264 * Finds only the derived labels attached to an issue and returns only
265 * their names.
266 * @return {Array<string>} Array of label names.
267 */
268 get _derivedLabels() {
269 if (!this.issue || !this.issue.labelRefs) return [];
270 const labels = this.issue.labelRefs;
271 return labels.filter((l) => l.isDerived).map((l) => l.label);
272 }
273
274 /**
275 * Gets the displayName of the owner. Only uses the displayName if a
276 * userId also exists in the ref.
277 * @param {UserRef} ownerRef The owner of the issue.
278 * @return {string} The name of the owner for the edited issue.
279 */
280 _ownerDisplayName(ownerRef) {
281 return (ownerRef && ownerRef.userId) ? ownerRef.displayName : '';
282 }
283
284 /**
285 * Dispatches an action against the server to run "issue presubmit", a feature
286 * that warns the user about issue changes that violate configured rules.
287 * @param {Object=} issueDelta Changes currently present in the edit form.
288 * @param {string} commentContent Text the user is inputting for a comment.
289 */
290 _presubmitIssue(issueDelta = {}, commentContent) {
291 // Don't run this functionality if the element has disconnected. Important
292 // for preventing debounced code from running after an element no longer
293 // exists.
294 if (!this.isConnected) return;
295
296 if (Object.keys(issueDelta).length || commentContent) {
297 // TODO(crbug.com/monorail/8638): Make filter rules actually process
298 // the text for comments on the backend.
299 store.dispatch(issueV0.presubmit(this.issueRef, issueDelta));
300 }
301 }
302
303 /**
304 * Form change handler that runs presubmit on the form.
305 * @param {CustomEvent} evt
306 */
307 _onChange(evt) {
308 const {delta, commentContent} = evt.detail || {};
309
310 if (!this._debouncedPresubmit) {
311 this._debouncedPresubmit = debounce(
312 (delta, commentContent) => this._presubmitIssue(delta, commentContent),
313 this.presubmitDebounceTimeOut);
314 }
315 this._debouncedPresubmit(delta, commentContent);
316 }
317
318 /**
319 * Creates the list of statuses that the user sees in the status dropdown.
320 * @param {Array<StatusDef>} statusDefsArg The project configured StatusDefs.
321 * @param {StatusRef} currentStatusRef The status that the issue currently
322 * uses. Note that Monorail supports free text statuses that do not exist in
323 * a project config. Because of this, currentStatusRef may not exist in
324 * statusDefsArg.
325 * @return {Array<StatusRef|StatusDef>} Array of statuses a user can edit this
326 * issue to have.
327 */
328 _availableStatuses(statusDefsArg, currentStatusRef) {
329 let statusDefs = statusDefsArg || [];
330 statusDefs = statusDefs.filter((status) => !status.deprecated);
331 if (!currentStatusRef || statusDefs.find(
332 (status) => status.status === currentStatusRef.status)) {
333 return statusDefs;
334 }
335 return [currentStatusRef, ...statusDefs];
336 }
337}
338
339/**
340 * Asks the user for confirmation when they try to remove retriction labels.
341 * eg. Restrict-View-Google.
342 * @param {Array<LabelRef>} labelRefsRemoved The labels a user is removing
343 * from this issue.
344 * @return {boolean} Whether removing these labels is okay. ie: true if there
345 * are either no restrictions being removed or if the user approved the
346 * removal of the restrictions.
347 */
348export function allowRemovedRestrictions(labelRefsRemoved) {
349 if (!labelRefsRemoved) return true;
350 const removedRestrictions = labelRefsRemoved
351 .map(({label}) => label)
352 .filter((label) => label.toLowerCase().startsWith('restrict-'));
353 const removeRestrictionsMessage =
354 'You are removing these restrictions:\n' +
355 arrayToEnglish(removedRestrictions) + '\n' +
356 'This might allow more people to access this issue. Are you sure?';
357 return !removedRestrictions.length || confirm(removeRestrictionsMessage);
358}
359
360customElements.define('mr-edit-issue', MrEditIssue);