blob: 7877007b7b24abd6c272c55e33bbfbd9f586250c [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';
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +02006import deepEqual from 'deep-equal';
Copybara854996b2021-09-07 19:36:02 +00007
8import 'elements/chops/chops-button/chops-button.js';
9import 'elements/framework/mr-upload/mr-upload.js';
10import 'elements/framework/mr-star/mr-issue-star.js';
11import 'elements/chops/chops-checkbox/chops-checkbox.js';
12import 'elements/chops/chops-chip/chops-chip.js';
13import 'elements/framework/mr-error/mr-error.js';
14import 'elements/framework/mr-warning/mr-warning.js';
15import 'elements/help/mr-cue/mr-cue.js';
16import 'react/mr-react-autocomplete.tsx';
17import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
18import {store, connectStore} from 'reducers/base.js';
19import {UserInputError} from 'shared/errors.js';
20import {fieldTypes} from 'shared/issue-fields.js';
21import {displayNameToUserRef, labelStringToRef, componentStringToRef,
22 componentRefsToStrings, issueStringToRef, issueStringToBlockingRef,
23 issueRefToString, issueRefsToStrings, filteredUserDisplayNames,
24 valueToFieldValue, fieldDefToName,
25} from 'shared/convertersV0.js';
26import {arrayDifference, isEmptyObject, equalsIgnoreCase} from 'shared/helpers.js';
27import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
28import * as issueV0 from 'reducers/issueV0.js';
29import * as permissions from 'reducers/permissions.js';
30import * as projectV0 from 'reducers/projectV0.js';
31import * as userV0 from 'reducers/userV0.js';
32import * as ui from 'reducers/ui.js';
33import '../mr-edit-field/mr-edit-field.js';
34import '../mr-edit-field/mr-edit-status.js';
35import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
36 ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
37 ISSUE_EDIT_CC_PERMISSION,
38} from 'shared/consts/permissions.js';
39import {fieldDefsWithGroup, fieldDefsWithoutGroup, valuesForField,
40 HARDCODED_FIELD_GROUPS} from 'shared/metadata-helpers.js';
41import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
42import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
43import {MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
44
45
46
47/**
48 * `<mr-edit-metadata>`
49 *
50 * Editing form for either an approval or the overall issue.
51 *
52 */
53export class MrEditMetadata extends connectStore(LitElement) {
54 /** @override */
55 render() {
56 return html`
57 <style>
58 ${MD_PREVIEW_STYLES}
59 ${MD_STYLES}
60 mr-edit-metadata {
61 display: block;
62 font-size: var(--chops-main-font-size);
63 }
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020064 mr-edit-metadata[hidden] {
65 display: none;
66 }
Copybara854996b2021-09-07 19:36:02 +000067 mr-edit-metadata.edit-actions-right .edit-actions {
68 flex-direction: row-reverse;
69 text-align: right;
70 }
71 mr-edit-metadata.edit-actions-right .edit-actions chops-checkbox {
72 text-align: left;
73 }
74 .edit-actions chops-checkbox {
75 max-width: 200px;
76 margin-top: 2px;
77 flex-grow: 2;
78 text-align: right;
79 }
80 .edit-actions {
81 width: 100%;
82 max-width: 500px;
83 margin: 0.5em 0;
84 text-align: left;
85 display: flex;
86 flex-direction: row;
87 align-items: center;
88 }
89 .edit-actions chops-button {
90 flex-grow: 0;
91 flex-shrink: 0;
92 }
93 .edit-actions .emphasized {
94 margin-left: 0;
95 }
96 input {
97 box-sizing: border-box;
98 width: var(--mr-edit-field-width);
99 padding: var(--mr-edit-field-padding);
100 font-size: var(--chops-main-font-size);
101 }
102 mr-upload {
103 margin-bottom: 0.25em;
104 }
105 textarea {
106 font-family: var(--mr-toggled-font-family);
107 width: 100%;
108 margin: 0.25em 0;
109 box-sizing: border-box;
110 border: var(--chops-accessible-border);
111 height: 8em;
112 transition: height 0.1s ease-in-out;
113 padding: 0.5em 4px;
114 grid-column-start: 1;
115 grid-column-end: 2;
116 }
117 button.toggle {
118 background: none;
119 color: var(--chops-link-color);
120 border: 0;
121 width: 100%;
122 padding: 0.25em 0;
123 text-align: left;
124 }
125 button.toggle:hover {
126 cursor: pointer;
127 text-decoration: underline;
128 }
129 .presubmit-derived {
130 color: gray;
131 font-style: italic;
132 text-decoration-line: underline;
133 text-decoration-style: dotted;
134 }
135 .presubmit-derived-header {
136 color: gray;
137 font-weight: bold;
138 }
139 .discard-button {
140 margin-right: 16px;
141 margin-left: 16px;
142 }
143 .group {
144 width: 100%;
145 border: 1px solid hsl(0, 0%, 83%);
146 grid-column: 1 / -1;
147 margin: 0;
148 margin-bottom: 0.5em;
149 padding: 0;
150 padding-bottom: 0.5em;
151 }
152 .group legend {
153 margin-left: 130px;
154 }
155 .group-title {
156 text-align: center;
157 font-style: oblique;
158 margin-top: 4px;
159 margin-bottom: -8px;
160 }
161 .star-line {
162 display: flex;
163 align-items: center;
164 background: var(--chops-notice-bubble-bg);
165 border: var(--chops-notice-border);
166 justify-content: flex-start;
167 margin-top: 4px;
168 padding: 2px 4px 2px 8px;
169 }
170 mr-issue-star {
171 margin-right: 4px;
172 }
173 </style>
174 <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
175 rel="stylesheet">
176 <form id="editForm"
177 @submit=${this._save}
178 @keydown=${this._saveOnCtrlEnter}
179 >
180 <mr-cue cuePrefName=${cueNames.CODE_OF_CONDUCT}></mr-cue>
181 ${this._renderStarLine()}
182 <textarea
183 id="commentText"
184 placeholder="Add a comment"
185 @keyup=${this._processChanges}
186 aria-label="Comment"
187 ></textarea>
188 ${(this._renderMarkdown)
189 ? html`
190 <div class="markdown-preview preview-height-comment">
191 <div class="markdown">
192 ${unsafeHTML(renderMarkdown(this.getCommentContent()))}
193 </div>
194 </div>`: ''}
195 <mr-upload
196 ?hidden=${this.disableAttachments}
197 @change=${this._processChanges}
198 ></mr-upload>
199 <div class="input-grid">
200 ${this._renderEditFields()}
201 ${this._renderErrorsAndWarnings()}
202
203 <span></span>
204 <div class="edit-actions">
205 <chops-button
206 @click=${this._save}
207 class="save-changes emphasized"
208 ?disabled=${this.disabled}
209 title="Save changes (Ctrl+Enter / \u2318+Enter)"
210 >
211 Save changes
212 </chops-button>
213 <chops-button
214 @click=${this.discard}
215 class="de-emphasized discard-button"
216 ?disabled=${this.disabled}
217 >
218 Discard
219 </chops-button>
220
221 <chops-checkbox
222 id="sendEmail"
223 @checked-change=${this._sendEmailChecked}
224 ?checked=${this.sendEmail}
225 >Send email</chops-checkbox>
226 </div>
227
228 ${!this.isApproval ? this._renderPresubmitChanges() : ''}
229 </div>
230 </form>
231 `;
232 }
233
234 /**
235 * @return {TemplateResult}
236 * @private
237 */
238 _renderStarLine() {
239 if (this._canEditIssue || this.isApproval) return '';
240
241 return html`
242 <div class="star-line">
243 <mr-issue-star
244 .issueRef=${this.issueRef}
245 ></mr-issue-star>
246 <span>
247 ${this.isStarred ? `
248 You have voted for this issue and will receive notifications.
249 ` : `
250 Star this issue instead of commenting "+1 Me too!" to add a vote
251 and get notifications.`}
252 </span>
253 </div>
254 `;
255 }
256
257 /**
258 * @return {TemplateResult}
259 * @private
260 */
261 _renderPresubmitChanges() {
262 const {derivedCcs, derivedLabels} = this.presubmitResponse || {};
263 const hasCcs = derivedCcs && derivedCcs.length;
264 const hasLabels = derivedLabels && derivedLabels.length;
265 const hasDerivedValues = hasCcs || hasLabels;
266 return html`
267 ${hasDerivedValues ? html`
268 <span></span>
269 <div class="presubmit-derived-header">
270 Filter rules and components will add
271 </div>
272 ` : ''}
273
274 ${hasCcs? html`
275 <label
276 for="derived-ccs"
277 class="presubmit-derived-header"
278 >CC:</label>
279 <div id="derived-ccs">
280 ${derivedCcs.map((cc) => html`
281 <span
282 title=${cc.why}
283 class="presubmit-derived"
284 >${cc.value}</span>
285 `)}
286 </div>
287 ` : ''}
288
289 ${hasLabels ? html`
290 <label
291 for="derived-labels"
292 class="presubmit-derived-header"
293 >Labels:</label>
294 <div id="derived-labels">
295 ${derivedLabels.map((label) => html`
296 <span
297 title=${label.why}
298 class="presubmit-derived"
299 >${label.value}</span>
300 `)}
301 </div>
302 ` : ''}
303 `;
304 }
305
306 /**
307 * @return {TemplateResult}
308 * @private
309 */
310 _renderErrorsAndWarnings() {
311 const presubmitResponse = this.presubmitResponse || {};
312 const presubmitWarnings = presubmitResponse.warnings || [];
313 const presubmitErrors = presubmitResponse.errors || [];
314 return (this.error || presubmitWarnings.length || presubmitErrors.length) ?
315 html`
316 <span></span>
317 <div>
318 ${presubmitWarnings.map((warning) => html`
319 <mr-warning title=${warning.why}>${warning.value}</mr-warning>
320 `)}
321 <!-- TODO(ehmaldonado): Look into blocking submission on presubmit
322 -->
323 ${presubmitErrors.map((error) => html`
324 <mr-error title=${error.why}>${error.value}</mr-error>
325 `)}
326 ${this.error ? html`
327 <mr-error>${this.error}</mr-error>` : ''}
328 </div>
329 ` : '';
330 }
331
332 /**
333 * @return {TemplateResult}
334 * @private
335 */
336 _renderEditFields() {
337 if (this.isApproval) {
338 return html`
339 ${this._renderStatus()}
340 ${this._renderApprovers()}
341 ${this._renderFieldDefs()}
342
343 ${this._renderNicheFieldToggle()}
344 `;
345 }
346
347 return html`
348 ${this._canEditSummary ? this._renderSummary() : ''}
349 ${this._canEditStatus ? this._renderStatus() : ''}
350 ${this._canEditOwner ? this._renderOwner() : ''}
351 ${this._canEditCC ? this._renderCC() : ''}
352 ${this._canEditIssue ? html`
353 ${this._renderComponents()}
354
355 ${this._renderFieldDefs()}
356 ${this._renderRelatedIssues()}
357 ${this._renderLabels()}
358
359 ${this._renderNicheFieldToggle()}
360 ` : ''}
361 `;
362 }
363
364 /**
365 * @return {TemplateResult}
366 * @private
367 */
368 _renderSummary() {
369 return html`
370 <label for="summaryInput">Summary:</label>
371 <input
372 id="summaryInput"
373 value=${this.summary}
374 @keyup=${this._processChanges}
375 />
376 `;
377 }
378
379 /**
380 * @return {TemplateResult}
381 * @private
382 */
383 _renderOwner() {
384 const ownerPresubmit = this._ownerPresubmit;
385 return html`
386 <label for="ownerInput">
387 ${ownerPresubmit.message ? html`
388 <i
389 class=${`material-icons inline-${ownerPresubmit.icon}`}
390 title=${ownerPresubmit.message}
391 >${ownerPresubmit.icon}</i>
392 ` : ''}
393 Owner:
394 </label>
395 <mr-react-autocomplete
396 label="ownerInput"
397 vocabularyName="owner"
398 .placeholder=${ownerPresubmit.placeholder}
399 .value=${this._values.owner}
400 .onChange=${this._changeHandlers.owner}
401 ></mr-react-autocomplete>
402 `;
403 }
404
405 /**
406 * @return {TemplateResult}
407 * @private
408 */
409 _renderCC() {
410 return html`
411 <label for="ccInput">CC:</label>
412 <mr-react-autocomplete
413 label="ccInput"
414 vocabularyName="member"
415 .multiple=${true}
416 .fixedValues=${this._derivedCCs}
417 .value=${this._values.cc}
418 .onChange=${this._changeHandlers.cc}
419 ></mr-react-autocomplete>
420 `;
421 }
422
423 /**
424 * @return {TemplateResult}
425 * @private
426 */
427 _renderComponents() {
428 return html`
429 <label for="componentsInput">Components:</label>
430 <mr-react-autocomplete
431 label="componentsInput"
432 vocabularyName="component"
433 .multiple=${true}
434 .value=${this._values.components}
435 .onChange=${this._changeHandlers.components}
436 ></mr-react-autocomplete>
437 `;
438 }
439
440 /**
441 * @return {TemplateResult}
442 * @private
443 */
444 _renderApprovers() {
445 return this.hasApproverPrivileges && this.isApproval ? html`
446 <label for="approversInput_react">Approvers:</label>
447 <mr-edit-field
448 id="approversInput"
449 label="approversInput_react"
450 .type=${'USER_TYPE'}
451 .initialValues=${filteredUserDisplayNames(this.approvers)}
452 .name=${'approver'}
453 .acType=${'member'}
454 @change=${this._processChanges}
455 multi
456 ></mr-edit-field>
457 ` : '';
458 }
459
460 /**
461 * @return {TemplateResult}
462 * @private
463 */
464 _renderStatus() {
465 return this.statuses && this.statuses.length ? html`
466 <label for="statusInput">Status:</label>
467
468 <mr-edit-status
469 id="statusInput"
470 .initialStatus=${this.status}
471 .statuses=${this.statuses}
472 .mergedInto=${issueRefToString(this.mergedInto, this.projectName)}
473 ?isApproval=${this.isApproval}
474 @change=${this._processChanges}
475 ></mr-edit-status>
476 ` : '';
477 }
478
479 /**
480 * @return {TemplateResult}
481 * @private
482 */
483 _renderFieldDefs() {
484 return html`
485 ${fieldDefsWithGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((group) => html`
486 <fieldset class="group">
487 <legend>${group.groupName}</legend>
488 <div class="input-grid">
489 ${group.fieldDefs.map((field) => this._renderCustomField(field))}
490 </div>
491 </fieldset>
492 `)}
493
494 ${fieldDefsWithoutGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((field) => this._renderCustomField(field))}
495 `;
496 }
497
498 /**
499 * @return {TemplateResult}
500 * @private
501 */
502 _renderRelatedIssues() {
503 return html`
504 <label for="blockedOnInput">BlockedOn:</label>
505 <mr-react-autocomplete
506 label="blockedOnInput"
507 vocabularyName="component"
508 .multiple=${true}
509 .value=${this._values.blockedOn}
510 .onChange=${this._changeHandlers.blockedOn}
511 ></mr-react-autocomplete>
512
513 <label for="blockingInput">Blocking:</label>
514 <mr-react-autocomplete
515 label="blockingInput"
516 vocabularyName="component"
517 .multiple=${true}
518 .value=${this._values.blocking}
519 .onChange=${this._changeHandlers.blocking}
520 ></mr-react-autocomplete>
521 `;
522 }
523
524 /**
525 * @return {TemplateResult}
526 * @private
527 */
528 _renderLabels() {
529 return html`
530 <label for="labelsInput">Labels:</label>
531 <mr-react-autocomplete
532 label="labelsInput"
533 vocabularyName="label"
534 .multiple=${true}
535 .fixedValues=${this.derivedLabels}
536 .value=${this._values.labels}
537 .onChange=${this._changeHandlers.labels}
538 ></mr-react-autocomplete>
539 `;
540 }
541
542 /**
543 * @return {TemplateResult}
544 * @param {FieldDef} field The custom field beinf rendered.
545 * @private
546 */
547 _renderCustomField(field) {
548 if (!field || !field.fieldRef) return '';
549 const userCanEdit = this._userCanEdit(field);
550 const {fieldRef, isNiche, docstring, isMultivalued} = field;
551 const isHidden = (!this.showNicheFields && isNiche) || !userCanEdit;
552
553 let acType;
554 if (fieldRef.type === fieldTypes.USER_TYPE) {
555 acType = isMultivalued ? 'member' : 'owner';
556 }
557 return html`
558 <label
559 ?hidden=${isHidden}
560 for=${this._idForField(fieldRef.fieldName) + '_react'}
561 title=${docstring}
562 >
563 ${fieldRef.fieldName}:
564 </label>
565 <mr-edit-field
566 ?hidden=${isHidden}
567 id=${this._idForField(fieldRef.fieldName)}
568 .label=${this._idForField(fieldRef.fieldName) + '_react'}
569 .name=${fieldRef.fieldName}
570 .type=${fieldRef.type}
571 .options=${this._optionsForField(this.optionsPerEnumField, this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
572 .initialValues=${valuesForField(this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
573 .acType=${acType}
574 ?multi=${isMultivalued}
575 @change=${this._processChanges}
576 ></mr-edit-field>
577 `;
578 }
579
580 /**
581 * @return {TemplateResult}
582 * @private
583 */
584 _renderNicheFieldToggle() {
585 return this._nicheFieldCount ? html`
586 <span></span>
587 <button type="button" class="toggle" @click=${this.toggleNicheFields}>
588 <span ?hidden=${this.showNicheFields}>
589 Show all fields (${this._nicheFieldCount} currently hidden)
590 </span>
591 <span ?hidden=${!this.showNicheFields}>
592 Hide niche fields (${this._nicheFieldCount} currently shown)
593 </span>
594 </button>
595 ` : '';
596 }
597
598 /** @override */
599 static get properties() {
600 return {
601 fieldDefs: {type: Array},
602 formName: {type: String},
603 approvers: {type: Array},
604 setter: {type: Object},
605 summary: {type: String},
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +0200606 cc: {type: Array, hasChanged: _notDeepEqual},
607 components: {type: Array, hasChanged: _notDeepEqual},
Copybara854996b2021-09-07 19:36:02 +0000608 status: {type: String},
609 statuses: {type: Array},
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +0200610 blockedOn: {type: Array, hasChanged: _notDeepEqual},
611 blocking: {type: Array, hasChanged: _notDeepEqual},
Copybara854996b2021-09-07 19:36:02 +0000612 mergedInto: {type: Object},
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +0200613 ownerName: {type: String, hasChanged: _notDeepEqual},
614 labelNames: {type: Array, hasChanged: _notDeepEqual},
Copybara854996b2021-09-07 19:36:02 +0000615 derivedLabels: {type: Array},
616 _permissions: {type: Array},
617 phaseName: {type: String},
618 projectConfig: {type: Object},
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +0200619 projectName: {type: String, hasChanged: _notDeepEqual},
Copybara854996b2021-09-07 19:36:02 +0000620 isApproval: {type: Boolean},
621 isStarred: {type: Boolean},
622 issuePermissions: {type: Object},
623 issueRef: {type: Object},
624 hasApproverPrivileges: {type: Boolean},
625 showNicheFields: {type: Boolean},
626 disableAttachments: {type: Boolean},
627 error: {type: String},
628 sendEmail: {type: Boolean},
629 presubmitResponse: {type: Object},
630 fieldValueMap: {type: Object},
631 issueType: {type: String},
632 optionsPerEnumField: {type: String},
633 fieldGroups: {type: Object},
634 prefs: {type: Object},
635 saving: {type: Boolean},
636 isDirty: {type: Boolean},
637 _values: {type: Object},
638 _initialValues: {type: Object},
639 };
640 }
641
642 /** @override */
643 constructor() {
644 super();
645 this.summary = '';
646 this.ownerName = '';
647 this.sendEmail = true;
648 this.mergedInto = {};
649 this.issueRef = {};
650 this.fieldGroups = HARDCODED_FIELD_GROUPS;
651
652 this._permissions = {};
653 this.saving = false;
654 this.isDirty = false;
655 this.prefs = {};
656 this._values = {};
657 this._initialValues = {};
658
659 // Memoize change handlers so property updates don't cause excess rerenders.
660 this._changeHandlers = {
661 owner: this._onChange.bind(this, 'owner'),
662 cc: this._onChange.bind(this, 'cc'),
663 components: this._onChange.bind(this, 'components'),
664 labels: this._onChange.bind(this, 'labels'),
665 blockedOn: this._onChange.bind(this, 'blockedOn'),
666 blocking: this._onChange.bind(this, 'blocking'),
667 };
668 }
669
670 /** @override */
671 createRenderRoot() {
672 return this;
673 }
674
675 /** @override */
676 firstUpdated() {
677 this.hasRendered = true;
678 }
679
680 /** @override */
681 updated(changedProperties) {
682 if (changedProperties.has('ownerName') || changedProperties.has('cc')
683 || changedProperties.has('components')
684 || changedProperties.has('labelNames')
685 || changedProperties.has('blockedOn')
686 || changedProperties.has('blocking')
687 || changedProperties.has('projectName')) {
688 this._initialValues.owner = this.ownerName;
689 this._initialValues.cc = this._ccNames;
690 this._initialValues.components = componentRefsToStrings(this.components);
691 this._initialValues.labels = this.labelNames;
692 this._initialValues.blockedOn = issueRefsToStrings(this.blockedOn, this.projectName);
693 this._initialValues.blocking = issueRefsToStrings(this.blocking, this.projectName);
694
695 this._values = {...this._initialValues};
696 }
697 }
698
699 /**
700 * Getter for checking if the user has Markdown enabled.
701 * @return {boolean} Whether Markdown preview should be rendered or not.
702 */
703 get _renderMarkdown() {
704 if (!this.getCommentContent()) {
705 return false;
706 }
707 const enabled = this.prefs.get('render_markdown');
708 return shouldRenderMarkdown({project: this.projectName, enabled});
709 }
710
711 /**
712 * @return {boolean} Whether the "Save changes" button is disabled.
713 */
714 get disabled() {
715 return !this.isDirty || this.saving;
716 }
717
718 /**
719 * Set isDirty to a property instead of only using a getter to cause
720 * lit-element to re-render when dirty state change.
721 */
722 _updateIsDirty() {
723 if (!this.hasRendered) return;
724
725 const commentContent = this.getCommentContent();
726 const attachmentsElement = this.querySelector('mr-upload');
727 this.isDirty = !isEmptyObject(this.delta) || Boolean(commentContent) ||
728 attachmentsElement.hasAttachments;
729 }
730
731 get _nicheFieldCount() {
732 const fieldDefs = this.fieldDefs || [];
733 return fieldDefs.reduce((acc, fd) => acc + (fd.isNiche | 0), 0);
734 }
735
736 get _canEditIssue() {
737 const issuePermissions = this.issuePermissions || [];
738 return issuePermissions.includes(ISSUE_EDIT_PERMISSION);
739 }
740
741 get _canEditSummary() {
742 const issuePermissions = this.issuePermissions || [];
743 return this._canEditIssue ||
744 issuePermissions.includes(ISSUE_EDIT_SUMMARY_PERMISSION);
745 }
746
747 get _canEditStatus() {
748 const issuePermissions = this.issuePermissions || [];
749 return this._canEditIssue ||
750 issuePermissions.includes(ISSUE_EDIT_STATUS_PERMISSION);
751 }
752
753 get _canEditOwner() {
754 const issuePermissions = this.issuePermissions || [];
755 return this._canEditIssue ||
756 issuePermissions.includes(ISSUE_EDIT_OWNER_PERMISSION);
757 }
758
759 get _canEditCC() {
760 const issuePermissions = this.issuePermissions || [];
761 return this._canEditIssue ||
762 issuePermissions.includes(ISSUE_EDIT_CC_PERMISSION);
763 }
764
765 /**
766 * @return {Array<string>}
767 */
768 get _ccNames() {
769 const users = this.cc || [];
770 return filteredUserDisplayNames(users.filter((u) => !u.isDerived));
771 }
772
773 get _derivedCCs() {
774 const users = this.cc || [];
775 return filteredUserDisplayNames(users.filter((u) => u.isDerived));
776 }
777
778 get _ownerPresubmit() {
779 const response = this.presubmitResponse;
780 if (!response) return {};
781
782 const ownerView = {message: '', placeholder: '', icon: ''};
783
784 if (response.ownerAvailability) {
785 ownerView.message = response.ownerAvailability;
786 ownerView.icon = 'warning';
787 } else if (response.derivedOwners && response.derivedOwners.length) {
788 ownerView.placeholder = response.derivedOwners[0].value;
789 ownerView.message = response.derivedOwners[0].why;
790 ownerView.icon = 'info';
791 }
792 return ownerView;
793 }
794
795 /** @override */
796 stateChanged(state) {
797 this.fieldValueMap = issueV0.fieldValueMap(state);
798 this.issueType = issueV0.type(state);
799 this.issueRef = issueV0.viewedIssueRef(state);
800 this._permissions = permissions.byName(state);
801 this.presubmitResponse = issueV0.presubmitResponse(state);
802 this.projectConfig = projectV0.viewedConfig(state);
803 this.projectName = issueV0.viewedIssueRef(state).projectName;
804 this.issuePermissions = issueV0.permissions(state);
805 this.optionsPerEnumField = projectV0.optionsPerEnumField(state);
806 // Access boolean value from allStarredIssues
807 const starredIssues = issueV0.starredIssues(state);
808 this.isStarred = starredIssues.has(issueRefToString(this.issueRef));
809 this.prefs = userV0.prefs(state);
810 }
811
812 /** @override */
813 disconnectedCallback() {
814 super.disconnectedCallback();
815
816 store.dispatch(ui.reportDirtyForm(this.formName, false));
817 }
818
819 /**
820 * Resets the edit form values to their default values.
821 */
822 async reset() {
823 this._values = {...this._initialValues};
824
825 const form = this.querySelector('#editForm');
826 if (!form) return;
827
828 form.reset();
829 const statusInput = this.querySelector('#statusInput');
830 if (statusInput) {
831 statusInput.reset();
832 }
833
834 // Since custom elements containing <input> elements have the inputs
835 // wrapped in ShadowDOM, those inputs don't get reset with the rest of
836 // the form. Haven't been able to figure out a way to replicate form reset
837 // behavior with custom input elements.
838 if (this.isApproval) {
839 if (this.hasApproverPrivileges) {
840 const approversInput = this.querySelector(
841 '#approversInput');
842 if (approversInput) {
843 approversInput.reset();
844 }
845 }
846 }
847 this.querySelectorAll('mr-edit-field').forEach((el) => {
848 el.reset();
849 });
850
851 const uploader = this.querySelector('mr-upload');
852 if (uploader) {
853 uploader.reset();
854 }
855
856 // TODO(dtu, zhangtiff): Remove once all form fields are controlled.
857 await this.updateComplete;
858
859 this._processChanges();
860 }
861
862 /**
863 * @param {MouseEvent|SubmitEvent} event
864 * @private
865 */
866 _save(event) {
867 event.preventDefault();
868 this.save();
869 }
870
871 /**
872 * Users may use either Ctrl+Enter or Command+Enter to save an issue edit
873 * while the issue edit form is focused.
874 * @param {KeyboardEvent} event
875 * @private
876 */
877 _saveOnCtrlEnter(event) {
878 if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
879 event.preventDefault();
880 this.save();
881 }
882 }
883
884 /**
885 * Tells the parent to save the current edited values in the form.
886 * @fires CustomEvent#save
887 */
888 save() {
889 this.dispatchEvent(new CustomEvent('save'));
890 }
891
892 /**
893 * Tells the parent component that the user is trying to discard the form,
894 * if they confirm that that's what they're doing. The parent decides what
895 * to do in order to quit the editing session.
896 * @fires CustomEvent#discard
897 */
898 discard() {
899 const isDirty = this.isDirty;
900 if (!isDirty || confirm('Discard your changes?')) {
901 this.dispatchEvent(new CustomEvent('discard'));
902 }
903 }
904
905 /**
906 * Focuses the comment form.
907 */
908 async focus() {
909 await this.updateComplete;
910 this.querySelector('#commentText').focus();
911 }
912
913 /**
914 * Retrieves the value of the comment that the user added from the DOM.
915 * @return {string}
916 */
917 getCommentContent() {
918 if (!this.querySelector('#commentText')) {
919 return '';
920 }
921 return this.querySelector('#commentText').value;
922 }
923
924 async getAttachments() {
925 try {
926 return await this.querySelector('mr-upload').loadFiles();
927 } catch (e) {
928 this.error = `Error while loading file for attachment: ${e.message}`;
929 }
930 }
931
932 /**
933 * @param {FieldDef} field
934 * @return {boolean}
935 * @private
936 */
937 _userCanEdit(field) {
938 const fieldName = fieldDefToName(this.projectName, field);
939 if (!this._permissions[fieldName] ||
940 !this._permissions[fieldName].permissions) return false;
941 const userPerms = this._permissions[fieldName].permissions;
942 return userPerms.includes(permissions.FIELD_DEF_VALUE_EDIT);
943 }
944
945 /**
946 * Shows or hides custom fields with the "isNiche" attribute set to true.
947 */
948 toggleNicheFields() {
949 this.showNicheFields = !this.showNicheFields;
950 }
951
952 /**
953 * @return {IssueDelta}
954 * @throws {UserInputError}
955 */
956 get delta() {
957 try {
958 this.error = '';
959 return this._getDelta();
960 } catch (e) {
961 if (!(e instanceof UserInputError)) throw e;
962 this.error = e.message;
963 return {};
964 }
965 }
966
967 /**
968 * Generates a change between the initial Issue state and what the user
969 * inputted.
970 * @return {IssueDelta}
971 */
972 _getDelta() {
973 let result = {};
974
975 const {projectName, localId} = this.issueRef;
976
977 const statusInput = this.querySelector('#statusInput');
978 if (this._canEditStatus && statusInput) {
979 const statusDelta = statusInput.delta;
980 if (statusDelta.mergedInto) {
981 result.mergedIntoRef = issueStringToBlockingRef(
982 {projectName, localId}, statusDelta.mergedInto);
983 }
984 if (statusDelta.status) {
985 result.status = statusDelta.status;
986 }
987 }
988
989 if (this.isApproval) {
990 if (this._canEditIssue && this.hasApproverPrivileges) {
991 result = {
992 ...result,
993 ...this._changedValuesDom(
994 'approvers', 'approverRefs', displayNameToUserRef),
995 };
996 }
997 } else {
998 // TODO(zhangtiff): Consider representing baked-in fields such as owner,
999 // cc, and status similarly to custom fields to reduce repeated code.
1000
1001 if (this._canEditSummary) {
1002 const summaryInput = this.querySelector('#summaryInput');
1003 if (summaryInput) {
1004 const newSummary = summaryInput.value;
1005 if (newSummary !== this.summary) {
1006 result.summary = newSummary;
1007 }
1008 }
1009 }
1010
1011 if (this._values.owner !== this._initialValues.owner) {
1012 result.ownerRef = displayNameToUserRef(this._values.owner);
1013 }
1014
1015 const blockerAddFn = (refString) =>
1016 issueStringToBlockingRef({projectName, localId}, refString);
1017 const blockerRemoveFn = (refString) =>
1018 issueStringToRef(refString, projectName);
1019
1020 result = {
1021 ...result,
1022 ...this._changedValuesControlled(
1023 'cc', 'ccRefs', displayNameToUserRef),
1024 ...this._changedValuesControlled(
1025 'components', 'compRefs', componentStringToRef),
1026 ...this._changedValuesControlled(
1027 'labels', 'labelRefs', labelStringToRef),
1028 ...this._changedValuesControlled(
1029 'blockedOn', 'blockedOnRefs', blockerAddFn, blockerRemoveFn),
1030 ...this._changedValuesControlled(
1031 'blocking', 'blockingRefs', blockerAddFn, blockerRemoveFn),
1032 };
1033 }
1034
1035 if (this._canEditIssue) {
1036 const fieldDefs = this.fieldDefs || [];
1037 fieldDefs.forEach(({fieldRef}) => {
1038 const {fieldValsAdd = [], fieldValsRemove = []} =
1039 this._changedValuesDom(fieldRef.fieldName, 'fieldVals',
1040 valueToFieldValue.bind(null, fieldRef));
1041
1042 // Because multiple custom fields share the same "fieldVals" key in
1043 // delta, we hav to make sure to concatenate updated delta values with
1044 // old delta values.
1045 if (fieldValsAdd.length) {
1046 result.fieldValsAdd = [...(result.fieldValsAdd || []),
1047 ...fieldValsAdd];
1048 }
1049
1050 if (fieldValsRemove.length) {
1051 result.fieldValsRemove = [...(result.fieldValsRemove || []),
1052 ...fieldValsRemove];
1053 }
1054 });
1055 }
1056
1057 return result;
1058 }
1059
1060 /**
1061 * Computes delta values for a controlled input.
1062 * @param {string} fieldName The key in the values property to retrieve data.
1063 * from.
1064 * @param {string} responseKey The key in the delta Object that changes will be
1065 * saved in.
1066 * @param {function(string): any} addFn A function to specify how to format
1067 * the message for a given added field.
1068 * @param {function(string): any} removeFn A function to specify how to format
1069 * the message for a given removed field.
1070 * @return {Object} delta fragment for added and removed values.
1071 */
1072 _changedValuesControlled(fieldName, responseKey, addFn, removeFn) {
1073 const values = this._values[fieldName];
1074 const initialValues = this._initialValues[fieldName];
1075
1076 const valuesAdd = arrayDifference(values, initialValues, equalsIgnoreCase);
1077 const valuesRemove =
1078 arrayDifference(initialValues, values, equalsIgnoreCase);
1079
1080 return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
1081 }
1082
1083 /**
1084 * Gets changes values when reading from a legacy <mr-edit-field> element.
1085 * @param {string} fieldName Name of the form input we're checking values on.
1086 * @param {string} responseKey The key in the delta Object that changes will be
1087 * saved in.
1088 * @param {function(string): any} addFn A function to specify how to format
1089 * the message for a given added field.
1090 * @param {function(string): any} removeFn A function to specify how to format
1091 * the message for a given removed field.
1092 * @return {Object} delta fragment for added and removed values.
1093 */
1094 _changedValuesDom(fieldName, responseKey, addFn, removeFn) {
1095 const input = this.querySelector(`#${this._idForField(fieldName)}`);
1096 if (!input) return;
1097
1098 const valuesAdd = input.getValuesAdded();
1099 const valuesRemove = input.getValuesRemoved();
1100
1101 return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
1102 }
1103
1104 /**
1105 * Shared helper function for computing added and removed values for a
1106 * single field in a delta.
1107 * @param {Array<string>} valuesAdd The added values. For example, new CCed
1108 * users.
1109 * @param {Array<string>} valuesRemove Values that were removed in this edit.
1110 * @param {string} responseKey The key in the delta Object that changes will be
1111 * saved in.
1112 * @param {function(string): any} addFn A function to specify how to format
1113 * the message for a given added field.
1114 * @param {function(string): any} removeFn A function to specify how to format
1115 * the message for a given removed field.
1116 * @return {Object} delta fragment for added and removed values.
1117 */
1118 _changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn) {
1119 const delta = {};
1120
1121 if (valuesAdd && valuesAdd.length) {
1122 delta[responseKey + 'Add'] = valuesAdd.map(addFn);
1123 }
1124
1125 if (valuesRemove && valuesRemove.length) {
1126 delta[responseKey + 'Remove'] = valuesRemove.map(removeFn || addFn);
1127 }
1128
1129 return delta;
1130 }
1131
1132 /**
1133 * Generic onChange handler to be bound to each form field.
1134 * @param {string} key Unique name for the form field we're binding this
1135 * handler to. For example, 'owner', 'cc', or the name of a custom field.
1136 * @param {Event} event
1137 * @param {string|Array<string>} value The new form value.
1138 * @param {*} _reason
1139 */
1140 _onChange(key, event, value, _reason) {
1141 this._values = {...this._values, [key]: value};
1142 this._processChanges(event);
1143 }
1144
1145 /**
1146 * Event handler for running filter rules presubmit logic.
1147 * @param {Event} e
1148 */
1149 _processChanges(e) {
1150 if (e instanceof KeyboardEvent) {
1151 if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
1152 }
1153 this._updateIsDirty();
1154
1155 store.dispatch(ui.reportDirtyForm(this.formName, this.isDirty));
1156
1157 this.dispatchEvent(new CustomEvent('change', {
1158 detail: {
1159 delta: this.delta,
1160 commentContent: this.getCommentContent(),
1161 },
1162 }));
1163 }
1164
1165 _idForField(name) {
1166 return `${name}Input`;
1167 }
1168
1169 _optionsForField(optionsPerEnumField, fieldValueMap, fieldName, phaseName) {
1170 if (!optionsPerEnumField || !fieldName) return [];
1171 const key = fieldName.toLowerCase();
1172 if (!optionsPerEnumField.has(key)) return [];
1173 const options = [...optionsPerEnumField.get(key)];
1174 const values = valuesForField(fieldValueMap, fieldName, phaseName);
1175 values.forEach((v) => {
1176 const optionExists = options.find(
1177 (opt) => equalsIgnoreCase(opt.optionName, v));
1178 if (!optionExists) {
1179 // Note that enum fields which are not explicitly defined can be set,
1180 // such as in the case when an issue is moved.
1181 options.push({optionName: v});
1182 }
1183 });
1184 return options;
1185 }
1186
1187 _sendEmailChecked(evt) {
1188 this.sendEmail = evt.detail.checked;
1189 }
1190}
1191
Adrià Vilanova Martínez535e7312021-10-17 00:48:12 +02001192function _notDeepEqual(a, b) {
1193 return !deepEqual(a, b);
1194}
1195
Copybara854996b2021-09-07 19:36:02 +00001196customElements.define('mr-edit-metadata', MrEditMetadata);