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, css} from 'lit-element'; |
| 6 | |
| 7 | import {connectStore} from 'reducers/base.js'; |
| 8 | import 'elements/chops/chops-timestamp/chops-timestamp.js'; |
| 9 | import 'elements/framework/links/mr-issue-link/mr-issue-link.js'; |
| 10 | import 'elements/framework/links/mr-user-link/mr-user-link.js'; |
| 11 | import 'elements/framework/mr-issue-slo/mr-issue-slo.js'; |
| 12 | |
| 13 | import * as issueV0 from 'reducers/issueV0.js'; |
| 14 | import * as sitewide from 'reducers/sitewide.js'; |
| 15 | import * as userV0 from 'reducers/userV0.js'; |
| 16 | import './mr-field-values.js'; |
| 17 | import {isExperimentEnabled, SLO_EXPERIMENT} from 'shared/experiments.js'; |
| 18 | import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js'; |
| 19 | import {HARDCODED_FIELD_GROUPS, valuesForField, fieldDefsWithGroup, |
| 20 | fieldDefsWithoutGroup} from 'shared/metadata-helpers.js'; |
| 21 | import 'shared/typedef.js'; |
| 22 | import {AVAILABLE_CUES, cueNames, specToCueName, |
| 23 | cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js'; |
| 24 | import {SHARED_STYLES} from 'shared/shared-styles.js'; |
| 25 | |
| 26 | |
| 27 | /** |
| 28 | * `<mr-metadata>` |
| 29 | * |
| 30 | * Generalized metadata components, used for either approvals or issues. |
| 31 | * |
| 32 | */ |
| 33 | export class MrMetadata extends connectStore(LitElement) { |
| 34 | /** @override */ |
| 35 | static get styles() { |
| 36 | return [ |
| 37 | SHARED_STYLES, |
| 38 | css` |
| 39 | :host { |
| 40 | display: table; |
| 41 | table-layout: fixed; |
| 42 | width: 100%; |
| 43 | } |
| 44 | td, th { |
| 45 | padding: 0.5em 4px; |
| 46 | vertical-align: top; |
| 47 | text-overflow: ellipsis; |
| 48 | overflow: hidden; |
| 49 | } |
| 50 | td { |
| 51 | width: 60%; |
| 52 | } |
| 53 | td.allow-overflow { |
| 54 | overflow: visible; |
| 55 | } |
| 56 | th { |
| 57 | text-align: left; |
| 58 | width: 40%; |
| 59 | } |
| 60 | .group-separator { |
| 61 | border-top: var(--chops-normal-border); |
| 62 | } |
| 63 | .group-title { |
| 64 | font-weight: normal; |
| 65 | font-style: oblique; |
| 66 | border-bottom: var(--chops-normal-border); |
| 67 | text-align: center; |
| 68 | } |
| 69 | `, |
| 70 | ]; |
| 71 | } |
| 72 | |
| 73 | /** @override */ |
| 74 | render() { |
| 75 | return html` |
| 76 | <link href="https://fonts.googleapis.com/icon?family=Material+Icons" |
| 77 | rel="stylesheet"> |
| 78 | ${this._renderBuiltInFields()} |
| 79 | ${this._renderCustomFieldGroups()} |
| 80 | `; |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Helper for handling the rendering of built in fields. |
| 85 | * @return {Array<TemplateResult>} |
| 86 | */ |
| 87 | _renderBuiltInFields() { |
| 88 | return this.builtInFieldSpec.map((fieldName) => { |
| 89 | const fieldKey = fieldName.toLowerCase(); |
| 90 | |
| 91 | // Adding classes to table rows based on field names makes selecting |
| 92 | // rows with specific values easier, for example in tests. |
| 93 | let className = `row-${fieldKey}`; |
| 94 | |
| 95 | const cueName = specToCueName(fieldKey); |
| 96 | if (cueName) { |
| 97 | className = `cue-${cueName}`; |
| 98 | |
| 99 | if (!AVAILABLE_CUES.has(cueName)) return ''; |
| 100 | |
| 101 | return html` |
| 102 | <tr class=${className}> |
| 103 | <td colspan="2"> |
| 104 | <mr-cue cuePrefName=${cueName}></mr-cue> |
| 105 | </td> |
| 106 | </tr> |
| 107 | `; |
| 108 | } |
| 109 | |
| 110 | const isApprovalStatus = fieldKey === 'approvalstatus'; |
| 111 | const isMergedInto = fieldKey === 'mergedinto'; |
| 112 | |
| 113 | const fieldValueTemplate = this._renderBuiltInFieldValue(fieldName); |
| 114 | |
| 115 | if (!fieldValueTemplate) return ''; |
| 116 | |
| 117 | // Allow overflow to enable the FedRef popup to expand. |
| 118 | // TODO(jeffcarp): Look into a more elegant solution. |
| 119 | return html` |
| 120 | <tr class=${className}> |
| 121 | <th>${isApprovalStatus ? 'Status' : fieldName}:</th> |
| 122 | <td class=${isMergedInto ? 'allow-overflow' : ''}> |
| 123 | ${fieldValueTemplate} |
| 124 | </td> |
| 125 | </tr> |
| 126 | `; |
| 127 | }); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * A helper to display a single built-in field. |
| 132 | * |
| 133 | * @param {string} fieldName The name of the built in field to render. |
| 134 | * @return {TemplateResult|undefined} lit-html template for displaying the |
| 135 | * value of the built in field. If undefined, the rendering code assumes |
| 136 | * that the field should be hidden if empty. |
| 137 | */ |
| 138 | _renderBuiltInFieldValue(fieldName) { |
| 139 | // TODO(zhangtiff): Merge with code in shared/issue-fields.js for further |
| 140 | // de-duplication. |
| 141 | switch (fieldName.toLowerCase()) { |
| 142 | case 'approvalstatus': |
| 143 | return this.approvalStatus || EMPTY_FIELD_VALUE; |
| 144 | case 'approvers': |
| 145 | return this.approvers && this.approvers.length ? |
| 146 | this.approvers.map((approver) => html` |
| 147 | <mr-user-link |
| 148 | .userRef=${approver} |
| 149 | showAvailabilityIcon |
| 150 | ></mr-user-link> |
| 151 | <br /> |
| 152 | `) : EMPTY_FIELD_VALUE; |
| 153 | case 'setter': |
| 154 | return this.setter ? html` |
| 155 | <mr-user-link |
| 156 | .userRef=${this.setter} |
| 157 | showAvailabilityIcon |
| 158 | ></mr-user-link> |
| 159 | ` : undefined; // Hide the field when empty. |
| 160 | case 'owner': |
| 161 | return this.owner ? html` |
| 162 | <mr-user-link |
| 163 | .userRef=${this.owner} |
| 164 | showAvailabilityIcon |
| 165 | showAvailabilityText |
| 166 | ></mr-user-link> |
| 167 | ` : EMPTY_FIELD_VALUE; |
| 168 | case 'cc': |
| 169 | return this.cc && this.cc.length ? |
| 170 | this.cc.map((cc) => html` |
| 171 | <mr-user-link |
| 172 | .userRef=${cc} |
| 173 | showAvailabilityIcon |
| 174 | ></mr-user-link> |
| 175 | <br /> |
| 176 | `) : EMPTY_FIELD_VALUE; |
| 177 | case 'status': |
| 178 | return this.issueStatus ? html` |
| 179 | ${this.issueStatus.status} <em>${ |
| 180 | this.issueStatus.meansOpen ? '(Open)' : '(Closed)'} |
| 181 | </em>` : EMPTY_FIELD_VALUE; |
| 182 | case 'mergedinto': |
| 183 | // TODO(zhangtiff): This should use the project config to determine if a |
| 184 | // field allows merging rather than used a hard-coded value. |
| 185 | return this.issueStatus && this.issueStatus.status === 'Duplicate' ? |
| 186 | html` |
| 187 | <mr-issue-link |
| 188 | .projectName=${this.issueRef.projectName} |
| 189 | .issue=${this.mergedInto} |
| 190 | ></mr-issue-link> |
| 191 | `: undefined; // Hide the field when empty. |
| 192 | case 'components': |
| 193 | return (this.components && this.components.length) ? |
| 194 | this.components.map((comp) => html` |
| 195 | <a |
| 196 | href="/p/${this.issueRef.projectName |
| 197 | }/issues/list?q=component:${comp.path}" |
| 198 | title="${comp.path}${comp.docstring ? |
| 199 | ' = ' + comp.docstring : ''}" |
| 200 | > |
| 201 | ${comp.path}</a><br /> |
| 202 | `) : EMPTY_FIELD_VALUE; |
| 203 | case 'modified': |
| 204 | return this.modifiedTimestamp ? html` |
| 205 | <chops-timestamp |
| 206 | .timestamp=${this.modifiedTimestamp} |
| 207 | short |
| 208 | ></chops-timestamp> |
| 209 | ` : EMPTY_FIELD_VALUE; |
| 210 | case 'slo': |
| 211 | if (isExperimentEnabled( |
| 212 | SLO_EXPERIMENT, this.currentUser, this.queryParams)) { |
| 213 | return html`<mr-issue-slo .issue=${this.issue}></mr-issue-slo>`; |
| 214 | } else { |
| 215 | return; |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // Non-existent field. |
| 220 | return; |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Helper for handling the rendering of custom fields defined in a project |
| 225 | * config. |
| 226 | * @return {TemplateResult} lit-html template. |
| 227 | */ |
| 228 | _renderCustomFieldGroups() { |
| 229 | const grouped = fieldDefsWithGroup(this.fieldDefs, |
| 230 | this.fieldGroups, this.issueType); |
| 231 | const ungrouped = fieldDefsWithoutGroup(this.fieldDefs, |
| 232 | this.fieldGroups, this.issueType); |
| 233 | return html` |
| 234 | ${grouped.map((group) => html` |
| 235 | <tr> |
| 236 | <th class="group-title" colspan="2"> |
| 237 | ${group.groupName} |
| 238 | </th> |
| 239 | </tr> |
| 240 | ${this._renderCustomFields(group.fieldDefs)} |
| 241 | <tr> |
| 242 | <th class="group-separator" colspan="2"></th> |
| 243 | </tr> |
| 244 | `)} |
| 245 | |
| 246 | ${this._renderCustomFields(ungrouped)} |
| 247 | `; |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Helper for handling the rendering of built in fields. |
| 252 | * |
| 253 | * @param {Array<FieldDef>} fieldDefs Arrays of configurations Objects |
| 254 | * for fields to render. |
| 255 | * @return {Array<TemplateResult>} Array of lit-html templates to render, each |
| 256 | * representing a single table row for a custom field. |
| 257 | */ |
| 258 | _renderCustomFields(fieldDefs) { |
| 259 | if (!fieldDefs || !fieldDefs.length) return []; |
| 260 | return fieldDefs.map((field) => { |
| 261 | const fieldValues = valuesForField( |
| 262 | this.fieldValueMap, field.fieldRef.fieldName) || []; |
| 263 | return html` |
| 264 | <tr ?hidden=${field.isNiche && !fieldValues.length}> |
| 265 | <th title=${field.docstring}>${field.fieldRef.fieldName}:</th> |
| 266 | <td> |
| 267 | <mr-field-values |
| 268 | .name=${field.fieldRef.fieldName} |
| 269 | .type=${field.fieldRef.type} |
| 270 | .values=${fieldValues} |
| 271 | .projectName=${this.issueRef.projectName} |
| 272 | ></mr-field-values> |
| 273 | </td> |
| 274 | </tr> |
| 275 | `; |
| 276 | }); |
| 277 | } |
| 278 | |
| 279 | /** @override */ |
| 280 | static get properties() { |
| 281 | return { |
| 282 | /** |
| 283 | * An Array of Strings to specify which built in fields to display. |
| 284 | */ |
| 285 | builtInFieldSpec: {type: Array}, |
| 286 | approvalStatus: {type: Array}, |
| 287 | approvers: {type: Array}, |
| 288 | setter: {type: Object}, |
| 289 | cc: {type: Array}, |
| 290 | components: {type: Array}, |
| 291 | fieldDefs: {type: Array}, |
| 292 | fieldGroups: {type: Array}, |
| 293 | issue: {type: Object}, |
| 294 | issueStatus: {type: String}, |
| 295 | issueType: {type: String}, |
| 296 | mergedInto: {type: Object}, |
| 297 | modifiedTimestamp: {type: Number}, |
| 298 | owner: {type: Object}, |
| 299 | isApproval: {type: Boolean}, |
| 300 | issueRef: {type: Object}, |
| 301 | fieldValueMap: {type: Object}, |
| 302 | currentUser: {type: Object}, |
| 303 | queryParams: {type: Object}, |
| 304 | }; |
| 305 | } |
| 306 | |
| 307 | /** @override */ |
| 308 | constructor() { |
| 309 | super(); |
| 310 | this.isApproval = false; |
| 311 | this.fieldGroups = HARDCODED_FIELD_GROUPS; |
| 312 | this.issueRef = {}; |
| 313 | |
| 314 | // Default built in fields used by issue metadata. |
| 315 | this.builtInFieldSpec = [ |
| 316 | 'Owner', 'CC', cueNameToSpec(cueNames.AVAILABILITY_MSGS), |
| 317 | 'Status', 'MergedInto', 'Components', 'Modified', 'SLO', |
| 318 | ]; |
| 319 | this.fieldValueMap = new Map(); |
| 320 | |
| 321 | this.approvalStatus = undefined; |
| 322 | this.approvers = undefined; |
| 323 | this.setter = undefined; |
| 324 | this.cc = undefined; |
| 325 | this.components = undefined; |
| 326 | this.fieldDefs = undefined; |
| 327 | this.issue = undefined; |
| 328 | this.issueStatus = undefined; |
| 329 | this.issueType = undefined; |
| 330 | this.mergedInto = undefined; |
| 331 | this.owner = undefined; |
| 332 | this.modifiedTimestamp = undefined; |
| 333 | this.currentUser = undefined; |
| 334 | this.queryParams = {}; |
| 335 | } |
| 336 | |
| 337 | /** @override */ |
| 338 | connectedCallback() { |
| 339 | super.connectedCallback(); |
| 340 | |
| 341 | // This is set for accessibility. Do not override. |
| 342 | this.setAttribute('role', 'table'); |
| 343 | } |
| 344 | |
| 345 | /** @override */ |
| 346 | stateChanged(state) { |
| 347 | this.fieldValueMap = issueV0.fieldValueMap(state); |
| 348 | this.issue = issueV0.viewedIssue(state); |
| 349 | this.issueType = issueV0.type(state); |
| 350 | this.issueRef = issueV0.viewedIssueRef(state); |
| 351 | this.relatedIssues = issueV0.relatedIssues(state); |
| 352 | this.currentUser = userV0.currentUser(state); |
| 353 | this.queryParams = sitewide.queryParams(state); |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | customElements.define('mr-metadata', MrMetadata); |