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 };