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