github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/webapp/components/wpt-amend-metadata.js (about) 1 /** 2 * Copyright 2020 The WPT Dashboard Project. All rights reserved. 3 * Use of this source code is governed by a BSD-style license that can be 4 * found in the LICENSE file. 5 */ 6 7 import '../node_modules/@polymer/paper-dialog/paper-dialog.js'; 8 import '../node_modules/@polymer/paper-dialog-scrollable/paper-dialog-scrollable.js'; 9 import '../node_modules/@polymer/paper-input/paper-input.js'; 10 import '../node_modules/@polymer/paper-toast/paper-toast.js'; 11 import { html, PolymerElement } from '../node_modules/@polymer/polymer/polymer-element.js'; 12 import { LoadingState } from './loading-state.js'; 13 import { ProductInfo } from './product-info.js'; 14 import { PathInfo } from './path.js'; 15 16 const AmendMetadataMixin = (superClass) => class extends superClass { 17 static get properties() { 18 return { 19 selectedMetadata: { 20 type: Array, 21 value: [], 22 }, 23 hasSelections: { 24 type: Boolean, 25 computed: 'computeHasSelections(selectedMetadata)', 26 }, 27 selectedCells: { 28 type: Array, 29 value: [], 30 }, 31 isTriageMode: { 32 type: Boolean 33 }, 34 }; 35 } 36 37 static get observers() { 38 return [ 39 'pathChanged(path)', 40 ]; 41 } 42 43 pathChanged() { 44 this.selectedMetadata = []; 45 } 46 47 computeHasSelections(selectedMetadata) { 48 return selectedMetadata.length > 0; 49 } 50 51 handleClear(selectedMetadata) { 52 if (selectedMetadata.length === 0 && this.selectedCells.length) { 53 for (const cell of this.selectedCells) { 54 cell.removeAttribute('selected'); 55 } 56 this.selectedCells = []; 57 } 58 } 59 60 handleHover(td, canAmend) { 61 if (!canAmend) { 62 if (td.hasAttribute('triage')) { 63 td.removeAttribute('triage'); 64 } 65 return; 66 } 67 68 td.setAttribute('triage', 'triage'); 69 } 70 71 handleSelect(td, browser, test, toast) { 72 if (this.selectedMetadata.find(s => s.test === test && s.product === browser)) { 73 this.selectedMetadata = this.selectedMetadata.filter(s => !(s.test === test && s.product === browser)); 74 this.selectedCells = this.selectedCells.filter(c => c !== td); 75 td.removeAttribute('selected'); 76 } else { 77 const selected = { test: test, product: browser }; 78 this.selectedMetadata = [...this.selectedMetadata, selected]; 79 td.setAttribute('selected', 'selected'); 80 this.selectedCells.push(td); 81 } 82 83 if (this.selectedMetadata.length) { 84 toast.show(); 85 } 86 } 87 88 handleTriageModeChange(mode, toast) { 89 if (mode) { 90 toast.show(); 91 return; 92 } 93 94 if (this.selectedMetadata.length > 0) { 95 this.selectedMetadata = []; 96 } 97 toast.hide(); 98 } 99 100 triageToastMsg(arrayLen) { 101 if (arrayLen > 0) { 102 return arrayLen + ' ' + this.pluralize('test', arrayLen) + ' selected'; 103 } else { 104 return 'Select some cells to triage'; 105 } 106 } 107 }; 108 109 // AmendMetadata is a UI component that allows the user to associate a set of 110 // tests or test results with a URL (usually a link to a bug-tracker). It is 111 // commonly referred to as the 'triage UI'. 112 class AmendMetadata extends LoadingState(PathInfo(ProductInfo(PolymerElement))) { 113 static get is() { 114 return 'wpt-amend-metadata'; 115 } 116 117 static get template() { 118 return html` 119 <style> 120 img.browser { 121 height: 26px; 122 width: 26px; 123 position: relative; 124 margin-right: 10px; 125 } 126 paper-button { 127 text-transform: none; 128 margin-top: 5px; 129 } 130 paper-input { 131 text-transform: none; 132 align-items: center; 133 margin-bottom: 20px; 134 margin-left: 10px; 135 } 136 .metadata-entry { 137 display: flex; 138 align-items: center; 139 margin-top: 20px; 140 margin-bottom: 0px; 141 } 142 .link { 143 align-items: center; 144 color: white; 145 } 146 li { 147 margin-top: 5px; 148 margin-left: 30px; 149 } 150 .list { 151 text-overflow: ellipsis; 152 overflow: hidden; 153 white-space: nowrap; 154 max-width: 100ch; 155 display: inline-block; 156 vertical-align: bottom; 157 } 158 </style> 159 <paper-dialog id="dialog"> 160 <h3>Triage Failing Tests (<a href="https://github.com/web-platform-tests/wpt-metadata/blob/master/README.md" target="_blank">See metadata documentation</a>)</h3> 161 <paper-dialog-scrollable> 162 <template is="dom-repeat" items="[[displayedMetadata]]" as="node"> 163 <div class="metadata-entry"> 164 <img class="browser" src="[[displayMetadataLogo(node.product)]]"> 165 : 166 <paper-input label="Bug URL" on-input="handleFieldInput" value="{{node.url}}" autofocus></paper-input> 167 <template is="dom-if" if="[[!node.product]]"> 168 <paper-input label="Label" on-input="handleFieldInput" value="{{node.label}}"></paper-input> 169 </template> 170 </div> 171 <template is="dom-repeat" items="[[node.tests]]" as="test"> 172 <li> 173 <div class="list"> [[test]] </div> 174 <template is="dom-if" if="[[hasSearchURL(node.product)]]"> 175 <a href="[[getSearchURL(test, node.product)]]" target="_blank"> [Search for bug] </a> 176 </template> 177 <template is="dom-if" if="[[hasFileIssueURL(node.product)]]"> 178 <a href="[[getFileIssueURL(test)]]" target="_blank"> [File test-level issue] </a> 179 </template> 180 </li> 181 </template> 182 </template> 183 </paper-dialog-scrollable> 184 <div class="buttons"> 185 <paper-button onclick="[[close]]">Dismiss</paper-button> 186 <paper-button disabled="[[triageSubmitDisabled]]" onclick="[[triage]]" dialog-confirm>Triage</paper-button> 187 </div> 188 </paper-dialog> 189 <paper-toast id="show-pr" duration="10000"><span>[[errorMessage]]</span><a class="link" target="_blank" href="[[prLink]]">[[prText]]</a></paper-toast> 190 `; 191 } 192 193 static get properties() { 194 return { 195 prLink: String, 196 prText: String, 197 errorMessage: String, 198 fieldsFilled: Object, 199 selectedMetadata: { 200 type: Array, 201 notify: true, 202 }, 203 displayedMetadata: { 204 type: Array, 205 value: [] 206 }, 207 triageSubmitDisabled: { 208 type: Boolean, 209 value: true 210 } 211 }; 212 } 213 214 constructor() { 215 super(); 216 this.triage = this.triageSubmit.bind(this); 217 this.close = this.close.bind(this); 218 this.enter = this.triageOnEnter.bind(this); 219 } 220 221 get dialog() { 222 return this.$.dialog; 223 } 224 225 open() { 226 this.dialog.open(); 227 this.populateDisplayData(); 228 this.dialog.addEventListener('keydown', this.enter); 229 } 230 231 close() { 232 this.dialog.removeEventListener('keydown', this.enter); 233 this.triageSubmitDisabled = true; 234 this.selectedMetadata = []; 235 this.fieldsFilled = {filled: [], numEmpty: 0}; 236 this.dialog.close(); 237 } 238 239 triageSubmit() { 240 this.handleTriage(); 241 this.close(); 242 } 243 244 triageOnEnter(e) { 245 if (e.which === 13 && !this.triageSubmitDisabled) { 246 this.triageSubmit(); 247 } 248 } 249 250 getTriagedMetadataMap(displayedMetadata) { 251 var link = {}; 252 if (this.computePathIsATestFile(this.path)) { 253 link[this.path] = []; 254 for (const entry of displayedMetadata) { 255 if (entry.url === '') { 256 continue; 257 } 258 259 const results = []; 260 for (const test of entry.tests) { 261 results.push({ 'subtest': test }); 262 } 263 link[this.path].push({ 'url': entry.url, 'product': entry.product, 'results': results }); 264 } 265 } else { 266 for (const entry of displayedMetadata) { 267 // entry.url always exists while entry.label only exists when product is empty; 268 // in other words, a test-level triage. 269 if (entry.url === '' && !entry.label) { 270 continue; 271 } 272 273 for (const test of entry.tests) { 274 if (!(test in link)) { 275 link[test] = []; 276 } 277 const metadata = {}; 278 if (entry.url !== '') { 279 metadata['url'] = entry.url; 280 } 281 if (entry.product !== '') { 282 metadata['product'] = entry.product; 283 } 284 if (entry.label && entry.label !== '') { 285 metadata['label'] = entry.label; 286 } 287 link[test].push(metadata); 288 } 289 } 290 } 291 return link; 292 } 293 294 hasSearchURL(product) { 295 return [ 296 'chrome', 297 'chromium', 298 'deno', 299 'edge', 300 'firefox', 301 'node.js', 302 'safari', 303 'servo', 304 'wktr', 305 'webkitgtk', 306 ].includes(product); 307 } 308 309 getSearchURL(testName, product) { 310 if (this.computePathIsATestFile(testName)) { 311 // Remove name flags and extensions: https://web-platform-tests.org/writing-tests/file-names.html 312 testName = testName.split('.')[0]; 313 } else { 314 testName = testName.replace(/((\/\*)?$)/, ''); 315 } 316 317 if (product === 'chrome' || product === 'chromium' || product === 'edge') { 318 return `https://bugs.chromium.org/p/chromium/issues/list?q="${testName}"`; 319 } 320 321 if (product === 'deno') { 322 return `https://github.com/denoland/deno/issues?q="${testName}"`; 323 } 324 325 if (product === 'firefox') { 326 return `https://bugzilla.mozilla.org/buglist.cgi?quicksearch="${testName}"`; 327 } 328 329 if (product === 'node.js') { 330 return `https://github.com/nodejs/node/issues?q="${testName}"`; 331 } 332 333 if (product === 'safari' || product === 'wktr' || product === 'webkitgtk') { 334 return `https://bugs.webkit.org/buglist.cgi?quicksearch="${testName}"`; 335 } 336 337 if (product === 'servo') { 338 return `https://github.com/servo/servo/issues?q="${testName}"`; 339 } 340 } 341 342 hasFileIssueURL(product) { 343 // We only support filing issues for test-level problems 344 // (https://github.com/web-platform-tests/wpt.fyi/issues/2420). In this 345 // class the test-level product is represented by an empty string. 346 return product === ''; 347 } 348 349 getFileIssueURL(testName) { 350 const params = new URLSearchParams(); 351 params.append('title', `[compat2021] ${testName} fails due to test issue`); 352 params.append('labels', 'compat2021-test-issue'); 353 return `https://github.com/web-platform-tests/wpt-metadata/issues/new?${params}`; 354 } 355 356 populateDisplayData() { 357 this.displayedMetadata = []; 358 // Info to keep track of which fields have been filled. 359 this.fieldsFilled = {filled: [], numEmpty: 0}; 360 361 const browserMap = {}; 362 for (const entry of this.selectedMetadata) { 363 if (!(entry.product in browserMap)) { 364 browserMap[entry.product] = []; 365 } 366 367 let test = entry.test; 368 if (!this.computePathIsATestFile(this.path) && this.computePathIsASubfolder(test)) { 369 test = test + '/*'; 370 } 371 372 browserMap[entry.product].push(test); 373 } 374 375 for (const key in browserMap) { 376 let node = { product: key, url: '', tests: browserMap[key] }; 377 // when key (product) is empty, we will set a label field because 378 // this is a test-level triage. 379 if (key === '') { 380 node['label'] = ''; 381 } 382 this.displayedMetadata.push(node); 383 this.fieldsFilled.filled.push(false); 384 } 385 // A URL or label must be supplied for every triage item, 386 // which are all currently empty. 387 this.fieldsFilled.numEmpty = this.displayedMetadata.length; 388 } 389 390 handleFieldInput(event) { 391 // Detect which input was filled. 392 const index = event.model.__data.index; 393 const url = this.displayedMetadata[index].url; 394 const label = this.displayedMetadata[index].label; 395 396 // Check if the input is empty. 397 if (url === '' && (label === '' || label === undefined)) { 398 // If the field was previously considered filled, it's now empty. 399 if (this.fieldsFilled.filled[index]) { 400 this.fieldsFilled.numEmpty++; 401 } 402 this.fieldsFilled.filled[index] = false; 403 } else if (!this.fieldsFilled.filled[index]) { 404 // If the field was previously empty, it is now considered filled. 405 this.fieldsFilled.numEmpty--; 406 this.fieldsFilled.filled[index] = true; 407 } 408 409 // If all triage items have input, triage can be submitted. 410 this.triageSubmitDisabled = this.fieldsFilled.numEmpty > 0; 411 } 412 413 handleTriage() { 414 const url = new URL('/api/metadata/triage', window.location); 415 const toast = this.shadowRoot.querySelector('#show-pr'); 416 417 const triagedMetadataMap = this.getTriagedMetadataMap(this.displayedMetadata); 418 if (Object.keys(triagedMetadataMap).length === 0) { 419 this.selectedMetadata = []; 420 let errMsg = ''; 421 if (this.displayedMetadata.length > 0 && this.displayedMetadata[0].product === '') { 422 errMsg = 'Failed to triage: Bug URL and Label fields cannot both be empty.'; 423 } else { 424 errMsg = 'Failed to triage: Bug URLs cannot be empty.'; 425 } 426 this.errorMessage = errMsg; 427 toast.open(); 428 return; 429 } 430 431 const fetchOpts = { 432 method: 'PATCH', 433 body: JSON.stringify(triagedMetadataMap), 434 credentials: 'same-origin', 435 headers: { 436 'Content-Type': 'application/json' 437 }, 438 }; 439 440 window.fetch(url, fetchOpts).then( 441 async r => { 442 this.prText = ''; 443 this.prLink = ''; 444 this.errorMessage = ''; 445 let text = await r.text(); 446 if (!r.ok || r.status !== 200) { 447 throw new Error(`${r.status}: ${text}`); 448 } 449 450 return text; 451 }) 452 .then(text => { 453 this.prLink = text; 454 this.prText = 'Created PR: ' + text; 455 this.dispatchEvent(new CustomEvent('triagemetadata', { bubbles: true, composed: true })); 456 toast.open(); 457 }).catch(error => { 458 this.errorMessage = error.message; 459 toast.open(); 460 }); 461 462 this.selectedMetadata = []; 463 } 464 } 465 466 window.customElements.define(AmendMetadata.is, AmendMetadata); 467 468 export { AmendMetadataMixin, AmendMetadata };