blob: 7877007b7b24abd6c272c55e33bbfbd9f586250c [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {LitElement, html} from 'lit-element';
import deepEqual from 'deep-equal';
import 'elements/chops/chops-button/chops-button.js';
import 'elements/framework/mr-upload/mr-upload.js';
import 'elements/framework/mr-star/mr-issue-star.js';
import 'elements/chops/chops-checkbox/chops-checkbox.js';
import 'elements/chops/chops-chip/chops-chip.js';
import 'elements/framework/mr-error/mr-error.js';
import 'elements/framework/mr-warning/mr-warning.js';
import 'elements/help/mr-cue/mr-cue.js';
import 'react/mr-react-autocomplete.tsx';
import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
import {store, connectStore} from 'reducers/base.js';
import {UserInputError} from 'shared/errors.js';
import {fieldTypes} from 'shared/issue-fields.js';
import {displayNameToUserRef, labelStringToRef, componentStringToRef,
componentRefsToStrings, issueStringToRef, issueStringToBlockingRef,
issueRefToString, issueRefsToStrings, filteredUserDisplayNames,
valueToFieldValue, fieldDefToName,
} from 'shared/convertersV0.js';
import {arrayDifference, isEmptyObject, equalsIgnoreCase} from 'shared/helpers.js';
import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
import * as issueV0 from 'reducers/issueV0.js';
import * as permissions from 'reducers/permissions.js';
import * as projectV0 from 'reducers/projectV0.js';
import * as userV0 from 'reducers/userV0.js';
import * as ui from 'reducers/ui.js';
import '../mr-edit-field/mr-edit-field.js';
import '../mr-edit-field/mr-edit-status.js';
import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
ISSUE_EDIT_CC_PERMISSION,
} from 'shared/consts/permissions.js';
import {fieldDefsWithGroup, fieldDefsWithoutGroup, valuesForField,
HARDCODED_FIELD_GROUPS} from 'shared/metadata-helpers.js';
import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
import {MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
/**
* `<mr-edit-metadata>`
*
* Editing form for either an approval or the overall issue.
*
*/
export class MrEditMetadata extends connectStore(LitElement) {
/** @override */
render() {
return html`
<style>
${MD_PREVIEW_STYLES}
${MD_STYLES}
mr-edit-metadata {
display: block;
font-size: var(--chops-main-font-size);
}
mr-edit-metadata[hidden] {
display: none;
}
mr-edit-metadata.edit-actions-right .edit-actions {
flex-direction: row-reverse;
text-align: right;
}
mr-edit-metadata.edit-actions-right .edit-actions chops-checkbox {
text-align: left;
}
.edit-actions chops-checkbox {
max-width: 200px;
margin-top: 2px;
flex-grow: 2;
text-align: right;
}
.edit-actions {
width: 100%;
max-width: 500px;
margin: 0.5em 0;
text-align: left;
display: flex;
flex-direction: row;
align-items: center;
}
.edit-actions chops-button {
flex-grow: 0;
flex-shrink: 0;
}
.edit-actions .emphasized {
margin-left: 0;
}
input {
box-sizing: border-box;
width: var(--mr-edit-field-width);
padding: var(--mr-edit-field-padding);
font-size: var(--chops-main-font-size);
}
mr-upload {
margin-bottom: 0.25em;
}
textarea {
font-family: var(--mr-toggled-font-family);
width: 100%;
margin: 0.25em 0;
box-sizing: border-box;
border: var(--chops-accessible-border);
height: 8em;
transition: height 0.1s ease-in-out;
padding: 0.5em 4px;
grid-column-start: 1;
grid-column-end: 2;
}
button.toggle {
background: none;
color: var(--chops-link-color);
border: 0;
width: 100%;
padding: 0.25em 0;
text-align: left;
}
button.toggle:hover {
cursor: pointer;
text-decoration: underline;
}
.presubmit-derived {
color: gray;
font-style: italic;
text-decoration-line: underline;
text-decoration-style: dotted;
}
.presubmit-derived-header {
color: gray;
font-weight: bold;
}
.discard-button {
margin-right: 16px;
margin-left: 16px;
}
.group {
width: 100%;
border: 1px solid hsl(0, 0%, 83%);
grid-column: 1 / -1;
margin: 0;
margin-bottom: 0.5em;
padding: 0;
padding-bottom: 0.5em;
}
.group legend {
margin-left: 130px;
}
.group-title {
text-align: center;
font-style: oblique;
margin-top: 4px;
margin-bottom: -8px;
}
.star-line {
display: flex;
align-items: center;
background: var(--chops-notice-bubble-bg);
border: var(--chops-notice-border);
justify-content: flex-start;
margin-top: 4px;
padding: 2px 4px 2px 8px;
}
mr-issue-star {
margin-right: 4px;
}
</style>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
<form id="editForm"
@submit=${this._save}
@keydown=${this._saveOnCtrlEnter}
>
<mr-cue cuePrefName=${cueNames.CODE_OF_CONDUCT}></mr-cue>
${this._renderStarLine()}
<textarea
id="commentText"
placeholder="Add a comment"
@keyup=${this._processChanges}
aria-label="Comment"
></textarea>
${(this._renderMarkdown)
? html`
<div class="markdown-preview preview-height-comment">
<div class="markdown">
${unsafeHTML(renderMarkdown(this.getCommentContent()))}
</div>
</div>`: ''}
<mr-upload
?hidden=${this.disableAttachments}
@change=${this._processChanges}
></mr-upload>
<div class="input-grid">
${this._renderEditFields()}
${this._renderErrorsAndWarnings()}
<span></span>
<div class="edit-actions">
<chops-button
@click=${this._save}
class="save-changes emphasized"
?disabled=${this.disabled}
title="Save changes (Ctrl+Enter / \u2318+Enter)"
>
Save changes
</chops-button>
<chops-button
@click=${this.discard}
class="de-emphasized discard-button"
?disabled=${this.disabled}
>
Discard
</chops-button>
<chops-checkbox
id="sendEmail"
@checked-change=${this._sendEmailChecked}
?checked=${this.sendEmail}
>Send email</chops-checkbox>
</div>
${!this.isApproval ? this._renderPresubmitChanges() : ''}
</div>
</form>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderStarLine() {
if (this._canEditIssue || this.isApproval) return '';
return html`
<div class="star-line">
<mr-issue-star
.issueRef=${this.issueRef}
></mr-issue-star>
<span>
${this.isStarred ? `
You have voted for this issue and will receive notifications.
` : `
Star this issue instead of commenting "+1 Me too!" to add a vote
and get notifications.`}
</span>
</div>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderPresubmitChanges() {
const {derivedCcs, derivedLabels} = this.presubmitResponse || {};
const hasCcs = derivedCcs && derivedCcs.length;
const hasLabels = derivedLabels && derivedLabels.length;
const hasDerivedValues = hasCcs || hasLabels;
return html`
${hasDerivedValues ? html`
<span></span>
<div class="presubmit-derived-header">
Filter rules and components will add
</div>
` : ''}
${hasCcs? html`
<label
for="derived-ccs"
class="presubmit-derived-header"
>CC:</label>
<div id="derived-ccs">
${derivedCcs.map((cc) => html`
<span
title=${cc.why}
class="presubmit-derived"
>${cc.value}</span>
`)}
</div>
` : ''}
${hasLabels ? html`
<label
for="derived-labels"
class="presubmit-derived-header"
>Labels:</label>
<div id="derived-labels">
${derivedLabels.map((label) => html`
<span
title=${label.why}
class="presubmit-derived"
>${label.value}</span>
`)}
</div>
` : ''}
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderErrorsAndWarnings() {
const presubmitResponse = this.presubmitResponse || {};
const presubmitWarnings = presubmitResponse.warnings || [];
const presubmitErrors = presubmitResponse.errors || [];
return (this.error || presubmitWarnings.length || presubmitErrors.length) ?
html`
<span></span>
<div>
${presubmitWarnings.map((warning) => html`
<mr-warning title=${warning.why}>${warning.value}</mr-warning>
`)}
<!-- TODO(ehmaldonado): Look into blocking submission on presubmit
-->
${presubmitErrors.map((error) => html`
<mr-error title=${error.why}>${error.value}</mr-error>
`)}
${this.error ? html`
<mr-error>${this.error}</mr-error>` : ''}
</div>
` : '';
}
/**
* @return {TemplateResult}
* @private
*/
_renderEditFields() {
if (this.isApproval) {
return html`
${this._renderStatus()}
${this._renderApprovers()}
${this._renderFieldDefs()}
${this._renderNicheFieldToggle()}
`;
}
return html`
${this._canEditSummary ? this._renderSummary() : ''}
${this._canEditStatus ? this._renderStatus() : ''}
${this._canEditOwner ? this._renderOwner() : ''}
${this._canEditCC ? this._renderCC() : ''}
${this._canEditIssue ? html`
${this._renderComponents()}
${this._renderFieldDefs()}
${this._renderRelatedIssues()}
${this._renderLabels()}
${this._renderNicheFieldToggle()}
` : ''}
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderSummary() {
return html`
<label for="summaryInput">Summary:</label>
<input
id="summaryInput"
value=${this.summary}
@keyup=${this._processChanges}
/>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderOwner() {
const ownerPresubmit = this._ownerPresubmit;
return html`
<label for="ownerInput">
${ownerPresubmit.message ? html`
<i
class=${`material-icons inline-${ownerPresubmit.icon}`}
title=${ownerPresubmit.message}
>${ownerPresubmit.icon}</i>
` : ''}
Owner:
</label>
<mr-react-autocomplete
label="ownerInput"
vocabularyName="owner"
.placeholder=${ownerPresubmit.placeholder}
.value=${this._values.owner}
.onChange=${this._changeHandlers.owner}
></mr-react-autocomplete>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderCC() {
return html`
<label for="ccInput">CC:</label>
<mr-react-autocomplete
label="ccInput"
vocabularyName="member"
.multiple=${true}
.fixedValues=${this._derivedCCs}
.value=${this._values.cc}
.onChange=${this._changeHandlers.cc}
></mr-react-autocomplete>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderComponents() {
return html`
<label for="componentsInput">Components:</label>
<mr-react-autocomplete
label="componentsInput"
vocabularyName="component"
.multiple=${true}
.value=${this._values.components}
.onChange=${this._changeHandlers.components}
></mr-react-autocomplete>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderApprovers() {
return this.hasApproverPrivileges && this.isApproval ? html`
<label for="approversInput_react">Approvers:</label>
<mr-edit-field
id="approversInput"
label="approversInput_react"
.type=${'USER_TYPE'}
.initialValues=${filteredUserDisplayNames(this.approvers)}
.name=${'approver'}
.acType=${'member'}
@change=${this._processChanges}
multi
></mr-edit-field>
` : '';
}
/**
* @return {TemplateResult}
* @private
*/
_renderStatus() {
return this.statuses && this.statuses.length ? html`
<label for="statusInput">Status:</label>
<mr-edit-status
id="statusInput"
.initialStatus=${this.status}
.statuses=${this.statuses}
.mergedInto=${issueRefToString(this.mergedInto, this.projectName)}
?isApproval=${this.isApproval}
@change=${this._processChanges}
></mr-edit-status>
` : '';
}
/**
* @return {TemplateResult}
* @private
*/
_renderFieldDefs() {
return html`
${fieldDefsWithGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((group) => html`
<fieldset class="group">
<legend>${group.groupName}</legend>
<div class="input-grid">
${group.fieldDefs.map((field) => this._renderCustomField(field))}
</div>
</fieldset>
`)}
${fieldDefsWithoutGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((field) => this._renderCustomField(field))}
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderRelatedIssues() {
return html`
<label for="blockedOnInput">BlockedOn:</label>
<mr-react-autocomplete
label="blockedOnInput"
vocabularyName="component"
.multiple=${true}
.value=${this._values.blockedOn}
.onChange=${this._changeHandlers.blockedOn}
></mr-react-autocomplete>
<label for="blockingInput">Blocking:</label>
<mr-react-autocomplete
label="blockingInput"
vocabularyName="component"
.multiple=${true}
.value=${this._values.blocking}
.onChange=${this._changeHandlers.blocking}
></mr-react-autocomplete>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderLabels() {
return html`
<label for="labelsInput">Labels:</label>
<mr-react-autocomplete
label="labelsInput"
vocabularyName="label"
.multiple=${true}
.fixedValues=${this.derivedLabels}
.value=${this._values.labels}
.onChange=${this._changeHandlers.labels}
></mr-react-autocomplete>
`;
}
/**
* @return {TemplateResult}
* @param {FieldDef} field The custom field beinf rendered.
* @private
*/
_renderCustomField(field) {
if (!field || !field.fieldRef) return '';
const userCanEdit = this._userCanEdit(field);
const {fieldRef, isNiche, docstring, isMultivalued} = field;
const isHidden = (!this.showNicheFields && isNiche) || !userCanEdit;
let acType;
if (fieldRef.type === fieldTypes.USER_TYPE) {
acType = isMultivalued ? 'member' : 'owner';
}
return html`
<label
?hidden=${isHidden}
for=${this._idForField(fieldRef.fieldName) + '_react'}
title=${docstring}
>
${fieldRef.fieldName}:
</label>
<mr-edit-field
?hidden=${isHidden}
id=${this._idForField(fieldRef.fieldName)}
.label=${this._idForField(fieldRef.fieldName) + '_react'}
.name=${fieldRef.fieldName}
.type=${fieldRef.type}
.options=${this._optionsForField(this.optionsPerEnumField, this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
.initialValues=${valuesForField(this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
.acType=${acType}
?multi=${isMultivalued}
@change=${this._processChanges}
></mr-edit-field>
`;
}
/**
* @return {TemplateResult}
* @private
*/
_renderNicheFieldToggle() {
return this._nicheFieldCount ? html`
<span></span>
<button type="button" class="toggle" @click=${this.toggleNicheFields}>
<span ?hidden=${this.showNicheFields}>
Show all fields (${this._nicheFieldCount} currently hidden)
</span>
<span ?hidden=${!this.showNicheFields}>
Hide niche fields (${this._nicheFieldCount} currently shown)
</span>
</button>
` : '';
}
/** @override */
static get properties() {
return {
fieldDefs: {type: Array},
formName: {type: String},
approvers: {type: Array},
setter: {type: Object},
summary: {type: String},
cc: {type: Array, hasChanged: _notDeepEqual},
components: {type: Array, hasChanged: _notDeepEqual},
status: {type: String},
statuses: {type: Array},
blockedOn: {type: Array, hasChanged: _notDeepEqual},
blocking: {type: Array, hasChanged: _notDeepEqual},
mergedInto: {type: Object},
ownerName: {type: String, hasChanged: _notDeepEqual},
labelNames: {type: Array, hasChanged: _notDeepEqual},
derivedLabels: {type: Array},
_permissions: {type: Array},
phaseName: {type: String},
projectConfig: {type: Object},
projectName: {type: String, hasChanged: _notDeepEqual},
isApproval: {type: Boolean},
isStarred: {type: Boolean},
issuePermissions: {type: Object},
issueRef: {type: Object},
hasApproverPrivileges: {type: Boolean},
showNicheFields: {type: Boolean},
disableAttachments: {type: Boolean},
error: {type: String},
sendEmail: {type: Boolean},
presubmitResponse: {type: Object},
fieldValueMap: {type: Object},
issueType: {type: String},
optionsPerEnumField: {type: String},
fieldGroups: {type: Object},
prefs: {type: Object},
saving: {type: Boolean},
isDirty: {type: Boolean},
_values: {type: Object},
_initialValues: {type: Object},
};
}
/** @override */
constructor() {
super();
this.summary = '';
this.ownerName = '';
this.sendEmail = true;
this.mergedInto = {};
this.issueRef = {};
this.fieldGroups = HARDCODED_FIELD_GROUPS;
this._permissions = {};
this.saving = false;
this.isDirty = false;
this.prefs = {};
this._values = {};
this._initialValues = {};
// Memoize change handlers so property updates don't cause excess rerenders.
this._changeHandlers = {
owner: this._onChange.bind(this, 'owner'),
cc: this._onChange.bind(this, 'cc'),
components: this._onChange.bind(this, 'components'),
labels: this._onChange.bind(this, 'labels'),
blockedOn: this._onChange.bind(this, 'blockedOn'),
blocking: this._onChange.bind(this, 'blocking'),
};
}
/** @override */
createRenderRoot() {
return this;
}
/** @override */
firstUpdated() {
this.hasRendered = true;
}
/** @override */
updated(changedProperties) {
if (changedProperties.has('ownerName') || changedProperties.has('cc')
|| changedProperties.has('components')
|| changedProperties.has('labelNames')
|| changedProperties.has('blockedOn')
|| changedProperties.has('blocking')
|| changedProperties.has('projectName')) {
this._initialValues.owner = this.ownerName;
this._initialValues.cc = this._ccNames;
this._initialValues.components = componentRefsToStrings(this.components);
this._initialValues.labels = this.labelNames;
this._initialValues.blockedOn = issueRefsToStrings(this.blockedOn, this.projectName);
this._initialValues.blocking = issueRefsToStrings(this.blocking, this.projectName);
this._values = {...this._initialValues};
}
}
/**
* Getter for checking if the user has Markdown enabled.
* @return {boolean} Whether Markdown preview should be rendered or not.
*/
get _renderMarkdown() {
if (!this.getCommentContent()) {
return false;
}
const enabled = this.prefs.get('render_markdown');
return shouldRenderMarkdown({project: this.projectName, enabled});
}
/**
* @return {boolean} Whether the "Save changes" button is disabled.
*/
get disabled() {
return !this.isDirty || this.saving;
}
/**
* Set isDirty to a property instead of only using a getter to cause
* lit-element to re-render when dirty state change.
*/
_updateIsDirty() {
if (!this.hasRendered) return;
const commentContent = this.getCommentContent();
const attachmentsElement = this.querySelector('mr-upload');
this.isDirty = !isEmptyObject(this.delta) || Boolean(commentContent) ||
attachmentsElement.hasAttachments;
}
get _nicheFieldCount() {
const fieldDefs = this.fieldDefs || [];
return fieldDefs.reduce((acc, fd) => acc + (fd.isNiche | 0), 0);
}
get _canEditIssue() {
const issuePermissions = this.issuePermissions || [];
return issuePermissions.includes(ISSUE_EDIT_PERMISSION);
}
get _canEditSummary() {
const issuePermissions = this.issuePermissions || [];
return this._canEditIssue ||
issuePermissions.includes(ISSUE_EDIT_SUMMARY_PERMISSION);
}
get _canEditStatus() {
const issuePermissions = this.issuePermissions || [];
return this._canEditIssue ||
issuePermissions.includes(ISSUE_EDIT_STATUS_PERMISSION);
}
get _canEditOwner() {
const issuePermissions = this.issuePermissions || [];
return this._canEditIssue ||
issuePermissions.includes(ISSUE_EDIT_OWNER_PERMISSION);
}
get _canEditCC() {
const issuePermissions = this.issuePermissions || [];
return this._canEditIssue ||
issuePermissions.includes(ISSUE_EDIT_CC_PERMISSION);
}
/**
* @return {Array<string>}
*/
get _ccNames() {
const users = this.cc || [];
return filteredUserDisplayNames(users.filter((u) => !u.isDerived));
}
get _derivedCCs() {
const users = this.cc || [];
return filteredUserDisplayNames(users.filter((u) => u.isDerived));
}
get _ownerPresubmit() {
const response = this.presubmitResponse;
if (!response) return {};
const ownerView = {message: '', placeholder: '', icon: ''};
if (response.ownerAvailability) {
ownerView.message = response.ownerAvailability;
ownerView.icon = 'warning';
} else if (response.derivedOwners && response.derivedOwners.length) {
ownerView.placeholder = response.derivedOwners[0].value;
ownerView.message = response.derivedOwners[0].why;
ownerView.icon = 'info';
}
return ownerView;
}
/** @override */
stateChanged(state) {
this.fieldValueMap = issueV0.fieldValueMap(state);
this.issueType = issueV0.type(state);
this.issueRef = issueV0.viewedIssueRef(state);
this._permissions = permissions.byName(state);
this.presubmitResponse = issueV0.presubmitResponse(state);
this.projectConfig = projectV0.viewedConfig(state);
this.projectName = issueV0.viewedIssueRef(state).projectName;
this.issuePermissions = issueV0.permissions(state);
this.optionsPerEnumField = projectV0.optionsPerEnumField(state);
// Access boolean value from allStarredIssues
const starredIssues = issueV0.starredIssues(state);
this.isStarred = starredIssues.has(issueRefToString(this.issueRef));
this.prefs = userV0.prefs(state);
}
/** @override */
disconnectedCallback() {
super.disconnectedCallback();
store.dispatch(ui.reportDirtyForm(this.formName, false));
}
/**
* Resets the edit form values to their default values.
*/
async reset() {
this._values = {...this._initialValues};
const form = this.querySelector('#editForm');
if (!form) return;
form.reset();
const statusInput = this.querySelector('#statusInput');
if (statusInput) {
statusInput.reset();
}
// Since custom elements containing <input> elements have the inputs
// wrapped in ShadowDOM, those inputs don't get reset with the rest of
// the form. Haven't been able to figure out a way to replicate form reset
// behavior with custom input elements.
if (this.isApproval) {
if (this.hasApproverPrivileges) {
const approversInput = this.querySelector(
'#approversInput');
if (approversInput) {
approversInput.reset();
}
}
}
this.querySelectorAll('mr-edit-field').forEach((el) => {
el.reset();
});
const uploader = this.querySelector('mr-upload');
if (uploader) {
uploader.reset();
}
// TODO(dtu, zhangtiff): Remove once all form fields are controlled.
await this.updateComplete;
this._processChanges();
}
/**
* @param {MouseEvent|SubmitEvent} event
* @private
*/
_save(event) {
event.preventDefault();
this.save();
}
/**
* Users may use either Ctrl+Enter or Command+Enter to save an issue edit
* while the issue edit form is focused.
* @param {KeyboardEvent} event
* @private
*/
_saveOnCtrlEnter(event) {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
this.save();
}
}
/**
* Tells the parent to save the current edited values in the form.
* @fires CustomEvent#save
*/
save() {
this.dispatchEvent(new CustomEvent('save'));
}
/**
* Tells the parent component that the user is trying to discard the form,
* if they confirm that that's what they're doing. The parent decides what
* to do in order to quit the editing session.
* @fires CustomEvent#discard
*/
discard() {
const isDirty = this.isDirty;
if (!isDirty || confirm('Discard your changes?')) {
this.dispatchEvent(new CustomEvent('discard'));
}
}
/**
* Focuses the comment form.
*/
async focus() {
await this.updateComplete;
this.querySelector('#commentText').focus();
}
/**
* Retrieves the value of the comment that the user added from the DOM.
* @return {string}
*/
getCommentContent() {
if (!this.querySelector('#commentText')) {
return '';
}
return this.querySelector('#commentText').value;
}
async getAttachments() {
try {
return await this.querySelector('mr-upload').loadFiles();
} catch (e) {
this.error = `Error while loading file for attachment: ${e.message}`;
}
}
/**
* @param {FieldDef} field
* @return {boolean}
* @private
*/
_userCanEdit(field) {
const fieldName = fieldDefToName(this.projectName, field);
if (!this._permissions[fieldName] ||
!this._permissions[fieldName].permissions) return false;
const userPerms = this._permissions[fieldName].permissions;
return userPerms.includes(permissions.FIELD_DEF_VALUE_EDIT);
}
/**
* Shows or hides custom fields with the "isNiche" attribute set to true.
*/
toggleNicheFields() {
this.showNicheFields = !this.showNicheFields;
}
/**
* @return {IssueDelta}
* @throws {UserInputError}
*/
get delta() {
try {
this.error = '';
return this._getDelta();
} catch (e) {
if (!(e instanceof UserInputError)) throw e;
this.error = e.message;
return {};
}
}
/**
* Generates a change between the initial Issue state and what the user
* inputted.
* @return {IssueDelta}
*/
_getDelta() {
let result = {};
const {projectName, localId} = this.issueRef;
const statusInput = this.querySelector('#statusInput');
if (this._canEditStatus && statusInput) {
const statusDelta = statusInput.delta;
if (statusDelta.mergedInto) {
result.mergedIntoRef = issueStringToBlockingRef(
{projectName, localId}, statusDelta.mergedInto);
}
if (statusDelta.status) {
result.status = statusDelta.status;
}
}
if (this.isApproval) {
if (this._canEditIssue && this.hasApproverPrivileges) {
result = {
...result,
...this._changedValuesDom(
'approvers', 'approverRefs', displayNameToUserRef),
};
}
} else {
// TODO(zhangtiff): Consider representing baked-in fields such as owner,
// cc, and status similarly to custom fields to reduce repeated code.
if (this._canEditSummary) {
const summaryInput = this.querySelector('#summaryInput');
if (summaryInput) {
const newSummary = summaryInput.value;
if (newSummary !== this.summary) {
result.summary = newSummary;
}
}
}
if (this._values.owner !== this._initialValues.owner) {
result.ownerRef = displayNameToUserRef(this._values.owner);
}
const blockerAddFn = (refString) =>
issueStringToBlockingRef({projectName, localId}, refString);
const blockerRemoveFn = (refString) =>
issueStringToRef(refString, projectName);
result = {
...result,
...this._changedValuesControlled(
'cc', 'ccRefs', displayNameToUserRef),
...this._changedValuesControlled(
'components', 'compRefs', componentStringToRef),
...this._changedValuesControlled(
'labels', 'labelRefs', labelStringToRef),
...this._changedValuesControlled(
'blockedOn', 'blockedOnRefs', blockerAddFn, blockerRemoveFn),
...this._changedValuesControlled(
'blocking', 'blockingRefs', blockerAddFn, blockerRemoveFn),
};
}
if (this._canEditIssue) {
const fieldDefs = this.fieldDefs || [];
fieldDefs.forEach(({fieldRef}) => {
const {fieldValsAdd = [], fieldValsRemove = []} =
this._changedValuesDom(fieldRef.fieldName, 'fieldVals',
valueToFieldValue.bind(null, fieldRef));
// Because multiple custom fields share the same "fieldVals" key in
// delta, we hav to make sure to concatenate updated delta values with
// old delta values.
if (fieldValsAdd.length) {
result.fieldValsAdd = [...(result.fieldValsAdd || []),
...fieldValsAdd];
}
if (fieldValsRemove.length) {
result.fieldValsRemove = [...(result.fieldValsRemove || []),
...fieldValsRemove];
}
});
}
return result;
}
/**
* Computes delta values for a controlled input.
* @param {string} fieldName The key in the values property to retrieve data.
* from.
* @param {string} responseKey The key in the delta Object that changes will be
* saved in.
* @param {function(string): any} addFn A function to specify how to format
* the message for a given added field.
* @param {function(string): any} removeFn A function to specify how to format
* the message for a given removed field.
* @return {Object} delta fragment for added and removed values.
*/
_changedValuesControlled(fieldName, responseKey, addFn, removeFn) {
const values = this._values[fieldName];
const initialValues = this._initialValues[fieldName];
const valuesAdd = arrayDifference(values, initialValues, equalsIgnoreCase);
const valuesRemove =
arrayDifference(initialValues, values, equalsIgnoreCase);
return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
}
/**
* Gets changes values when reading from a legacy <mr-edit-field> element.
* @param {string} fieldName Name of the form input we're checking values on.
* @param {string} responseKey The key in the delta Object that changes will be
* saved in.
* @param {function(string): any} addFn A function to specify how to format
* the message for a given added field.
* @param {function(string): any} removeFn A function to specify how to format
* the message for a given removed field.
* @return {Object} delta fragment for added and removed values.
*/
_changedValuesDom(fieldName, responseKey, addFn, removeFn) {
const input = this.querySelector(`#${this._idForField(fieldName)}`);
if (!input) return;
const valuesAdd = input.getValuesAdded();
const valuesRemove = input.getValuesRemoved();
return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
}
/**
* Shared helper function for computing added and removed values for a
* single field in a delta.
* @param {Array<string>} valuesAdd The added values. For example, new CCed
* users.
* @param {Array<string>} valuesRemove Values that were removed in this edit.
* @param {string} responseKey The key in the delta Object that changes will be
* saved in.
* @param {function(string): any} addFn A function to specify how to format
* the message for a given added field.
* @param {function(string): any} removeFn A function to specify how to format
* the message for a given removed field.
* @return {Object} delta fragment for added and removed values.
*/
_changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn) {
const delta = {};
if (valuesAdd && valuesAdd.length) {
delta[responseKey + 'Add'] = valuesAdd.map(addFn);
}
if (valuesRemove && valuesRemove.length) {
delta[responseKey + 'Remove'] = valuesRemove.map(removeFn || addFn);
}
return delta;
}
/**
* Generic onChange handler to be bound to each form field.
* @param {string} key Unique name for the form field we're binding this
* handler to. For example, 'owner', 'cc', or the name of a custom field.
* @param {Event} event
* @param {string|Array<string>} value The new form value.
* @param {*} _reason
*/
_onChange(key, event, value, _reason) {
this._values = {...this._values, [key]: value};
this._processChanges(event);
}
/**
* Event handler for running filter rules presubmit logic.
* @param {Event} e
*/
_processChanges(e) {
if (e instanceof KeyboardEvent) {
if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
}
this._updateIsDirty();
store.dispatch(ui.reportDirtyForm(this.formName, this.isDirty));
this.dispatchEvent(new CustomEvent('change', {
detail: {
delta: this.delta,
commentContent: this.getCommentContent(),
},
}));
}
_idForField(name) {
return `${name}Input`;
}
_optionsForField(optionsPerEnumField, fieldValueMap, fieldName, phaseName) {
if (!optionsPerEnumField || !fieldName) return [];
const key = fieldName.toLowerCase();
if (!optionsPerEnumField.has(key)) return [];
const options = [...optionsPerEnumField.get(key)];
const values = valuesForField(fieldValueMap, fieldName, phaseName);
values.forEach((v) => {
const optionExists = options.find(
(opt) => equalsIgnoreCase(opt.optionName, v));
if (!optionExists) {
// Note that enum fields which are not explicitly defined can be set,
// such as in the case when an issue is moved.
options.push({optionName: v});
}
});
return options;
}
_sendEmailChecked(evt) {
this.sendEmail = evt.detail.checked;
}
}
function _notDeepEqual(a, b) {
return !deepEqual(a, b);
}
customElements.define('mr-edit-metadata', MrEditMetadata);