| // Copyright 2019 The Chromium Authors |
| // 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); |