Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | // 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 | |
| 5 | import {LitElement, html} from 'lit-element'; |
| 6 | import 'elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js'; |
| 7 | import 'elements/framework/mr-error/mr-error.js'; |
| 8 | import 'react/mr-react-autocomplete.tsx'; |
| 9 | import {prpcClient} from 'prpc-client-instance.js'; |
| 10 | import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js'; |
| 11 | import {TEXT_TO_STATUS_ENUM} from 'shared/consts/approval.js'; |
| 12 | |
| 13 | |
| 14 | export const NO_UPDATES_MESSAGE = |
| 15 | 'User lacks approver perms for approval in all issues.'; |
| 16 | export const NO_APPROVALS_MESSAGE = 'These issues don\'t have any approvals.'; |
| 17 | |
| 18 | export class MrBulkApprovalUpdate extends LitElement { |
| 19 | /** @override */ |
| 20 | render() { |
| 21 | return html` |
| 22 | <style> |
| 23 | mr-bulk-approval-update { |
| 24 | display: block; |
| 25 | margin-top: 30px; |
| 26 | position: relative; |
| 27 | } |
| 28 | button.clickable-text { |
| 29 | background: none; |
| 30 | border: 0; |
| 31 | color: hsl(0, 0%, 39%); |
| 32 | cursor: pointer; |
| 33 | text-decoration: underline; |
| 34 | } |
| 35 | .hidden { |
| 36 | display: none; !important; |
| 37 | } |
| 38 | .message { |
| 39 | background-color: beige; |
| 40 | width: 500px; |
| 41 | } |
| 42 | .note { |
| 43 | color: hsl(0, 0%, 25%); |
| 44 | font-size: 0.85em; |
| 45 | font-style: italic; |
| 46 | } |
| 47 | mr-bulk-approval-update table { |
| 48 | border: 1px dotted black; |
| 49 | cellspacing: 0; |
| 50 | cellpadding: 3; |
| 51 | } |
| 52 | #approversInput { |
| 53 | border-style: none; |
| 54 | } |
| 55 | </style> |
| 56 | <button |
| 57 | class="js-showApprovals clickable-text" |
| 58 | ?hidden=${this.approvalsFetched} |
| 59 | @click=${this.fetchApprovals} |
| 60 | >Show Approvals</button> |
| 61 | ${this.approvals.length ? html` |
| 62 | <form> |
| 63 | <table> |
| 64 | <tbody><tr> |
| 65 | <th><label for="approvalSelect">Approval:</label></th> |
| 66 | <td> |
| 67 | <select |
| 68 | id="approvalSelect" |
| 69 | @change=${this._changeHandlers.approval} |
| 70 | > |
| 71 | ${this.approvals.map(({fieldRef}) => html` |
| 72 | <option |
| 73 | value=${fieldRef.fieldName} |
| 74 | .selected=${fieldRef.fieldName === this._values.approval} |
| 75 | > |
| 76 | ${fieldRef.fieldName} |
| 77 | </option> |
| 78 | `)} |
| 79 | </select> |
| 80 | </td> |
| 81 | </tr> |
| 82 | <tr> |
| 83 | <th><label for="approversInput">Approvers:</label></th> |
| 84 | <td> |
| 85 | <mr-react-autocomplete |
| 86 | label="approversInput" |
| 87 | vocabularyName="member" |
| 88 | .multiple=${true} |
| 89 | .value=${this._values.approvers} |
| 90 | .onChange=${this._changeHandlers.approvers} |
| 91 | ></mr-react-autocomplete> |
| 92 | </td> |
| 93 | </tr> |
| 94 | <tr><th><label for="statusInput">Status:</label></th> |
| 95 | <td> |
| 96 | <select |
| 97 | id="statusInput" |
| 98 | @change=${this._changeHandlers.status} |
| 99 | > |
| 100 | <option .selected=${!this._values.status}> |
| 101 | ${EMPTY_FIELD_VALUE} |
| 102 | </option> |
| 103 | ${this.statusOptions.map((status) => html` |
| 104 | <option |
| 105 | value=${status} |
| 106 | .selected=${status === this._values.status} |
| 107 | >${status}</option> |
| 108 | `)} |
| 109 | </select> |
| 110 | </td> |
| 111 | </tr> |
| 112 | <tr> |
| 113 | <th><label for="commentText">Comment:</label></th> |
| 114 | <td colspan="4"> |
| 115 | <textarea |
| 116 | cols="30" |
| 117 | rows="3" |
| 118 | id="commentText" |
| 119 | placeholder="Add an approval comment" |
| 120 | .value=${this._values.comment || ''} |
| 121 | @change=${this._changeHandlers.comment} |
| 122 | ></textarea> |
| 123 | </td> |
| 124 | </tr> |
| 125 | <tr> |
| 126 | <td> |
| 127 | <button |
| 128 | class="js-save" |
| 129 | @click=${this.save} |
| 130 | >Update Approvals only</button> |
| 131 | </td> |
| 132 | <td> |
| 133 | <span class="note"> |
| 134 | Note: Some approvals may not be updated if you lack |
| 135 | approver perms. |
| 136 | </span> |
| 137 | </td> |
| 138 | </tr> |
| 139 | </tbody></table> |
| 140 | </form> |
| 141 | `: ''} |
| 142 | <div class="message"> |
| 143 | ${this.responseMessage} |
| 144 | ${this.errorMessage ? html` |
| 145 | <mr-error>${this.errorMessage}</mr-error> |
| 146 | ` : ''} |
| 147 | </div> |
| 148 | `; |
| 149 | } |
| 150 | |
| 151 | /** @override */ |
| 152 | static get properties() { |
| 153 | return { |
| 154 | approvals: {type: Array}, |
| 155 | approvalsFetched: {type: Boolean}, |
| 156 | statusOptions: {type: Array}, |
| 157 | localIdsStr: {type: String}, |
| 158 | projectName: {type: String}, |
| 159 | responseMessage: {type: String}, |
| 160 | _values: {type: Object}, |
| 161 | }; |
| 162 | } |
| 163 | |
| 164 | /** @override */ |
| 165 | constructor() { |
| 166 | super(); |
| 167 | this.approvals = []; |
| 168 | this.statusOptions = Object.keys(TEXT_TO_STATUS_ENUM); |
| 169 | this.responseMessage = ''; |
| 170 | |
| 171 | this._values = {}; |
| 172 | this._changeHandlers = { |
| 173 | approval: this._onChange.bind(this, 'approval'), |
| 174 | approvers: this._onChange.bind(this, 'approvers'), |
| 175 | status: this._onChange.bind(this, 'status'), |
| 176 | comment: this._onChange.bind(this, 'comment'), |
| 177 | }; |
| 178 | } |
| 179 | |
| 180 | /** @override */ |
| 181 | createRenderRoot() { |
| 182 | return this; |
| 183 | } |
| 184 | |
| 185 | get issueRefs() { |
| 186 | const {projectName, localIdsStr} = this; |
| 187 | if (!projectName || !localIdsStr) return []; |
| 188 | const issueRefs = []; |
| 189 | const localIds = localIdsStr.split(','); |
| 190 | localIds.forEach((localId) => { |
| 191 | issueRefs.push({projectName: projectName, localId: localId}); |
| 192 | }); |
| 193 | return issueRefs; |
| 194 | } |
| 195 | |
| 196 | fetchApprovals(evt) { |
| 197 | const message = {issueRefs: this.issueRefs}; |
| 198 | prpcClient.call('monorail.Issues', 'ListApplicableFieldDefs', message).then( |
| 199 | (resp) => { |
| 200 | if (resp.fieldDefs) { |
| 201 | this.approvals = resp.fieldDefs.filter((fieldDef) => { |
| 202 | return fieldDef.fieldRef.type == 'APPROVAL_TYPE'; |
| 203 | }); |
| 204 | } |
| 205 | if (!this.approvals.length) { |
| 206 | this.errorMessage = NO_APPROVALS_MESSAGE; |
| 207 | } |
| 208 | this.approvalsFetched = true; |
| 209 | }, (error) => { |
| 210 | this.approvalsFetched = true; |
| 211 | this.errorMessage = error; |
| 212 | }); |
| 213 | } |
| 214 | |
| 215 | save(evt) { |
| 216 | this.responseMessage = ''; |
| 217 | this.errorMessage = ''; |
| 218 | this.toggleDisableForm(); |
| 219 | const selectedFieldDef = this.approvals.find( |
| 220 | (approval) => approval.fieldRef.fieldName === this._values.approval |
| 221 | ) || this.approvals[0]; |
| 222 | const message = { |
| 223 | issueRefs: this.issueRefs, |
| 224 | fieldRef: selectedFieldDef.fieldRef, |
| 225 | send_email: true, |
| 226 | }; |
| 227 | message.commentContent = this._values.comment; |
| 228 | const delta = {}; |
| 229 | if (this._values.status !== EMPTY_FIELD_VALUE) { |
| 230 | delta.status = TEXT_TO_STATUS_ENUM[this._values.status]; |
| 231 | } |
| 232 | const approversAdded = this._values.approvers; |
| 233 | if (approversAdded) { |
| 234 | delta.approverRefsAdd = approversAdded.map( |
| 235 | (name) => ({'displayName': name})); |
| 236 | } |
| 237 | if (Object.keys(delta).length) { |
| 238 | message.approvalDelta = delta; |
| 239 | } |
| 240 | prpcClient.call('monorail.Issues', 'BulkUpdateApprovals', message).then( |
| 241 | (resp) => { |
| 242 | if (resp.issueRefs && resp.issueRefs.length) { |
| 243 | const idsStr = Array.from(resp.issueRefs, |
| 244 | (ref) => ref.localId).join(', '); |
| 245 | this.responseMessage = `${this.getTimeStamp()}: Updated ${ |
| 246 | selectedFieldDef.fieldRef.fieldName} in issues: ${idsStr} (${ |
| 247 | resp.issueRefs.length} of ${this.issueRefs.length}).`; |
| 248 | this._values = {}; |
| 249 | } else { |
| 250 | this.errorMessage = NO_UPDATES_MESSAGE; |
| 251 | }; |
| 252 | this.toggleDisableForm(); |
| 253 | }, (error) => { |
| 254 | this.errorMessage = error; |
| 255 | this.toggleDisableForm(); |
| 256 | }); |
| 257 | } |
| 258 | |
| 259 | getTimeStamp() { |
| 260 | const date = new Date(); |
| 261 | return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`; |
| 262 | } |
| 263 | |
| 264 | toggleDisableForm() { |
| 265 | this.querySelectorAll('input, textarea, select, button').forEach( |
| 266 | (input) => { |
| 267 | input.disabled = !input.disabled; |
| 268 | }); |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Generic onChange handler to be bound to each form field. |
| 273 | * @param {string} key Unique name for the form field we're binding this |
| 274 | * handler to. For example, 'owner', 'cc', or the name of a custom field. |
| 275 | * @param {Event | React.SyntheticEvent} event |
| 276 | * @param {string} value The new form value. |
| 277 | */ |
| 278 | _onChange(key, event, value) { |
| 279 | this._values = {...this._values, [key]: value || event.target.value}; |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | customElements.define('mr-bulk-approval-update', MrBulkApprovalUpdate); |