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