| /** |
| * @license |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the 'License'); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an 'AS IS' BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| import {html} from 'lit-html'; |
| import {classMap} from 'lit-html/directives/class-map'; |
| import {repeat} from 'lit-html/directives/repeat'; |
| import { |
| css, |
| customElement, |
| internalProperty, |
| property, |
| PropertyValues, |
| query, |
| TemplateResult, |
| } from 'lit-element'; |
| import {GrLitElement} from '../lit/gr-lit-element'; |
| import '@polymer/paper-tooltip/paper-tooltip'; |
| import { |
| Action, |
| Category, |
| Link, |
| LinkIcon, |
| RunStatus, |
| Tag, |
| } from '../../api/checks'; |
| import {sharedStyles} from '../../styles/shared-styles'; |
| import { |
| allActions$, |
| checksPatchsetNumber$, |
| someProvidersAreLoading$, |
| RunResult, |
| CheckRun, |
| allLinks$, |
| } from '../../services/checks/checks-model'; |
| import { |
| allResults, |
| fireActionTriggered, |
| hasCompletedWithoutResults, |
| hasResultsOf, |
| iconForCategory, |
| iconForLink, |
| tooltipForLink, |
| } from '../../services/checks/checks-util'; |
| import { |
| assertIsDefined, |
| check, |
| checkRequiredProperty, |
| } from '../../utils/common-util'; |
| import {toggleClass, whenVisible} from '../../utils/dom-util'; |
| import {durationString} from '../../utils/date-util'; |
| import {charsOnly, pluralize} from '../../utils/string-util'; |
| import {fireRunSelectionReset, isAttemptSelected} from './gr-checks-util'; |
| import {ChecksTabState} from '../../types/events'; |
| import {ConfigInfo, PatchSetNumber} from '../../types/common'; |
| import {latestPatchNum$} from '../../services/change/change-model'; |
| import {appContext} from '../../services/app-context'; |
| import {repoConfig$} from '../../services/config/config-model'; |
| |
| @customElement('gr-result-row') |
| class GrResultRow extends GrLitElement { |
| @property() |
| result?: RunResult; |
| |
| @property() |
| isExpanded = false; |
| |
| @property({type: Boolean, reflect: true}) |
| isExpandable = false; |
| |
| @property() |
| shouldRender = false; |
| |
| static get styles() { |
| return [ |
| sharedStyles, |
| css` |
| :host { |
| display: contents; |
| } |
| :host([isexpandable]) { |
| cursor: pointer; |
| } |
| gr-result-expanded { |
| cursor: default; |
| } |
| tr { |
| border-top: 1px solid var(--border-color); |
| } |
| iron-icon.link { |
| color: var(--link-color); |
| margin-right: var(--spacing-m); |
| } |
| td.iconCol { |
| padding-left: var(--spacing-l); |
| padding-right: var(--spacing-m); |
| } |
| .iconCol div { |
| width: 20px; |
| } |
| .nameCol div { |
| width: 165px; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| .nameCol .attempt { |
| display: inline-block; |
| background-color: var(--tag-gray); |
| border-radius: var(--line-height-normal); |
| height: var(--line-height-normal); |
| width: var(--line-height-normal); |
| text-align: center; |
| vertical-align: top; |
| font-size: var(--font-size-small); |
| } |
| .summaryCol { |
| /* Forces this column to get the remaining space that is left over by |
| the other columns. */ |
| width: 99%; |
| } |
| .expanderCol div { |
| width: 20px; |
| } |
| td { |
| white-space: nowrap; |
| padding: var(--spacing-s); |
| } |
| td .summary-cell { |
| display: flex; |
| max-width: calc(100vw - 630px); |
| } |
| td .summary-cell .summary { |
| font-weight: var(--font-weight-bold); |
| flex-shrink: 1; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| margin-right: var(--spacing-s); |
| } |
| td .summary-cell .message { |
| flex-grow: 1; |
| /* Looks a bit stupid, but the idea is that .message shrinks first, |
| and only when that has shrunken to 0, then .summary should also |
| start shrinking (substantially). */ |
| flex-shrink: 1000000; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| tr:hover { |
| background: var(--hover-background-color); |
| } |
| tr td .summary-cell .links, |
| tr td .summary-cell .actions, |
| tr.collapsed:hover td .summary-cell .links, |
| tr.collapsed:hover td .summary-cell .actions, |
| :host(.dropdown-open) tr td .summary-cell .links, |
| :host(.dropdown-open) tr td .summary-cell .actions { |
| display: inline-block; |
| margin-left: var(--spacing-s); |
| } |
| tr.collapsed td .summary-cell .links, |
| tr.collapsed td .summary-cell .actions { |
| display: none; |
| } |
| tr.collapsed:hover .summary-cell .tags, |
| tr.collapsed:hover .summary-cell .label { |
| display: none; |
| } |
| td .summary-cell .tags .tag { |
| color: var(--primary-text-color); |
| display: inline-block; |
| border-radius: 20px; |
| background-color: var(--tag-background); |
| padding: 0 var(--spacing-m); |
| margin-left: var(--spacing-s); |
| } |
| td .summary-cell .label { |
| color: var(--primary-text-color); |
| display: inline-block; |
| border-radius: 20px; |
| background-color: var(--label-background); |
| padding: 0 var(--spacing-m); |
| margin-left: var(--spacing-s); |
| } |
| .tag.gray { |
| background-color: var(--tag-gray); |
| } |
| .tag.yellow { |
| background-color: var(--tag-yellow); |
| } |
| .tag.pink { |
| background-color: var(--tag-pink); |
| } |
| .tag.purple { |
| background-color: var(--tag-purple); |
| } |
| .tag.cyan { |
| background-color: var(--tag-cyan); |
| } |
| .tag.brown { |
| background-color: var(--tag-brown); |
| } |
| .actions gr-checks-action, |
| .actions gr-dropdown { |
| /* Fitting a 28px button into 20px line-height. */ |
| margin: -4px 0; |
| vertical-align: top; |
| } |
| #moreActions iron-icon { |
| color: var(--link-color); |
| } |
| #moreMessage { |
| display: none; |
| } |
| `, |
| ]; |
| } |
| |
| update(changedProperties: PropertyValues) { |
| if (changedProperties.has('result')) { |
| this.isExpandable = !!this.result?.summary && !!this.result?.message; |
| } |
| super.update(changedProperties); |
| } |
| |
| firstUpdated() { |
| const loading = this.shadowRoot?.querySelector('.container'); |
| assertIsDefined(loading, '"Loading" element'); |
| whenVisible(loading, () => this.setAttribute('shouldRender', 'true'), 200); |
| } |
| |
| render() { |
| if (!this.result) return ''; |
| if (!this.shouldRender) { |
| return html` |
| <tr class="container"> |
| <td class="iconCol"></td> |
| <td class="nameCol"> |
| <div><span class="loading">Loading...</span></div> |
| </td> |
| <td class="summaryCol"></td> |
| <td class="expanderCol"></td> |
| </tr> |
| `; |
| } |
| return html` |
| <tr class="${classMap({container: true, collapsed: !this.isExpanded})}"> |
| <td class="iconCol" @click="${this.toggleExpanded}"> |
| <div>${this.renderIcon()}</div> |
| </td> |
| <td class="nameCol" @click="${this.toggleExpanded}"> |
| <div> |
| <span>${this.result.checkName}</span> |
| <span class="attempt" ?hidden="${this.result.isSingleAttempt}" |
| >${this.result.attempt}</span |
| > |
| </div> |
| </td> |
| <td class="summaryCol"> |
| <div class="summary-cell"> |
| ${(this.result.links?.slice(0, 1) ?? []).map(this.renderLink)} |
| ${this.renderSummary(this.result.summary)} |
| <div class="message" @click="${this.toggleExpanded}"> |
| ${this.isExpanded ? '' : this.result.message} |
| </div> |
| <div class="tags"> |
| ${(this.result.tags ?? []).map(t => this.renderTag(t))} |
| </div> |
| ${this.renderLabel()} ${this.renderLinks()} ${this.renderActions()} |
| </div> |
| ${this.renderExpanded()} |
| </td> |
| <td class="expanderCol" @click="${this.toggleExpanded}"> |
| <div |
| class="show-hide" |
| role="switch" |
| tabindex="0" |
| ?hidden="${!this.isExpandable}" |
| ?aria-checked="${this.isExpanded}" |
| aria-label="${this.isExpanded |
| ? 'Collapse result row' |
| : 'Expand result row'}" |
| @keydown="${this.toggleExpanded}" |
| > |
| <iron-icon |
| icon="${this.isExpanded |
| ? 'gr-icons:expand-less' |
| : 'gr-icons:expand-more'}" |
| ></iron-icon> |
| </div> |
| </td> |
| </tr> |
| `; |
| } |
| |
| private renderExpanded() { |
| if (!this.isExpanded) return; |
| return html`<gr-result-expanded |
| .result="${this.result}" |
| ></gr-result-expanded>`; |
| } |
| |
| private toggleExpanded() { |
| if (!this.isExpandable) return; |
| this.isExpanded = !this.isExpanded; |
| } |
| |
| renderSummary(text?: string) { |
| if (!text) return; |
| return html` |
| <!-- The is for being able to shrink a tiny amount without |
| the text itself getting shrunk with an ellipsis. --> |
| <div class="summary" @click="${this.toggleExpanded}">${text} </div> |
| `; |
| } |
| |
| renderIcon() { |
| if (this.result?.status !== RunStatus.RUNNING) return; |
| return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`; |
| } |
| |
| renderLabel() { |
| const label = this.result?.labelName; |
| if (!label) return; |
| return html`<div class="label">${label}</div>`; |
| } |
| |
| renderLinks() { |
| const links = (this.result?.links ?? []).slice(1); |
| if (links.length === 0) return; |
| return html`<div class="links">${links.map(this.renderLink)}</div>`; |
| } |
| |
| renderLink(link: Link) { |
| const tooltipText = link.tooltip ?? tooltipForLink(link.icon); |
| return html`<a href="${link.url}" target="_blank" |
| ><iron-icon |
| aria-label="external link to details" |
| class="link" |
| icon="gr-icons:${iconForLink(link.icon)}" |
| ></iron-icon |
| ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a |
| >`; |
| } |
| |
| private renderActions() { |
| const actions = this.result?.actions ?? []; |
| if (actions.length === 0) return; |
| const overflowItems = actions.slice(2).map(action => { |
| return {...action, id: action.name}; |
| }); |
| return html`<div class="actions"> |
| ${this.renderAction(actions[0])} ${this.renderAction(actions[1])} |
| <gr-dropdown |
| id="moreActions" |
| link="" |
| vertical-offset="32" |
| horizontal-align="right" |
| @tap-item="${this.handleAction}" |
| @opened-changed="${(e: CustomEvent) => |
| toggleClass(this, 'dropdown-open', e.detail.value)}" |
| ?hidden="${overflowItems.length === 0}" |
| .items="${overflowItems}" |
| > |
| <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage"> |
| </iron-icon> |
| <span id="moreMessage">More</span> |
| </gr-dropdown> |
| </div>`; |
| } |
| |
| private handleAction(e: CustomEvent<Action>) { |
| fireActionTriggered(this, e.detail); |
| } |
| |
| private renderAction(action?: Action) { |
| if (!action) return; |
| return html`<gr-checks-action .action="${action}"></gr-checks-action>`; |
| } |
| |
| renderPrimaryActions() { |
| const primaryActions = (this.result?.actions ?? []).slice(0, 2); |
| if (primaryActions.length === 0) return; |
| return html` |
| <div class="primaryActions">${primaryActions.map(this.renderAction)}</div> |
| `; |
| } |
| |
| renderSecondaryActions() { |
| const secondaryActions = (this.result?.actions ?? []).slice(2); |
| if (secondaryActions.length === 0) return; |
| return html` |
| <div class="secondaryActions"> |
| ${secondaryActions.map(this.renderAction)} |
| </div> |
| `; |
| } |
| |
| renderTag(tag: Tag) { |
| return html`<div class="tag ${tag.color}">${tag.name}</div>`; |
| } |
| } |
| |
| @customElement('gr-result-expanded') |
| class GrResultExpanded extends GrLitElement { |
| @property() |
| result?: RunResult; |
| |
| @property() |
| repoConfig?: ConfigInfo; |
| |
| static get styles() { |
| return [ |
| sharedStyles, |
| css` |
| .message { |
| padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0; |
| } |
| `, |
| ]; |
| } |
| |
| constructor() { |
| super(); |
| this.subscribe('repoConfig', repoConfig$); |
| } |
| |
| render() { |
| if (!this.result) return ''; |
| return html` |
| <gr-endpoint-decorator name="check-result-expanded"> |
| <gr-endpoint-param |
| name="run" |
| .value="${this.result}" |
| ></gr-endpoint-param> |
| <gr-endpoint-param |
| name="result" |
| .value="${this.result}" |
| ></gr-endpoint-param> |
| <gr-formatted-text |
| no-trailing-margin="" |
| class="message" |
| content="${this.result.message}" |
| config="${this.repoConfig}" |
| ></gr-formatted-text> |
| </gr-endpoint-decorator> |
| `; |
| } |
| } |
| |
| const SHOW_ALL_THRESHOLDS: Map<Category | 'SUCCESS', number> = new Map(); |
| SHOW_ALL_THRESHOLDS.set(Category.ERROR, 20); |
| SHOW_ALL_THRESHOLDS.set(Category.WARNING, 20); |
| SHOW_ALL_THRESHOLDS.set(Category.INFO, 5); |
| SHOW_ALL_THRESHOLDS.set('SUCCESS', 5); |
| |
| @customElement('gr-checks-results') |
| export class GrChecksResults extends GrLitElement { |
| @query('#filterInput') |
| filterInput?: HTMLInputElement; |
| |
| @internalProperty() |
| filterRegExp = new RegExp(''); |
| |
| /** All runs. Shown should only the selected/filtered ones. */ |
| @property() |
| runs: CheckRun[] = []; |
| |
| /** |
| * Check names of runs that are selected in the runs panel. When this array |
| * is empty, then no run is selected and all runs should be shown. |
| */ |
| @property() |
| selectedRuns: string[] = []; |
| |
| @property() |
| actions: Action[] = []; |
| |
| @property() |
| links: Link[] = []; |
| |
| @property() |
| tabState?: ChecksTabState; |
| |
| @property() |
| someProvidersAreLoading = false; |
| |
| @property() |
| checksPatchsetNumber: PatchSetNumber | undefined = undefined; |
| |
| @property() |
| latestPatchsetNumber: PatchSetNumber | undefined = undefined; |
| |
| /** Maps checkName to selected attempt number. `undefined` means `latest`. */ |
| @property() |
| selectedAttempts: Map<string, number | undefined> = new Map< |
| string, |
| number | undefined |
| >(); |
| |
| /** Maintains the state of which result sections should show all results. */ |
| @internalProperty() |
| isShowAll: Map<Category | 'SUCCESS', boolean> = new Map(); |
| |
| /** |
| * This is the current state of whether a section is expanded or not. As long |
| * as isSectionExpandedByUser is false this will be computed by a default rule |
| * on every render. |
| */ |
| private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>(); |
| |
| /** |
| * Keeps track of whether the user intentionally changed the expansion state. |
| * Once this is true the default rule for showing a section expanded or not |
| * is not applied anymore. |
| */ |
| private isSectionExpandedByUser = new Map<Category | 'SUCCESS', boolean>(); |
| |
| private readonly checksService = appContext.checksService; |
| |
| constructor() { |
| super(); |
| this.subscribe('actions', allActions$); |
| this.subscribe('links', allLinks$); |
| this.subscribe('checksPatchsetNumber', checksPatchsetNumber$); |
| this.subscribe('latestPatchsetNumber', latestPatchNum$); |
| this.subscribe('someProvidersAreLoading', someProvidersAreLoading$); |
| } |
| |
| static get styles() { |
| return [ |
| sharedStyles, |
| css` |
| :host { |
| display: block; |
| background-color: var(--background-color-secondary); |
| } |
| .header { |
| display: block; |
| background-color: var(--background-color-primary); |
| padding: var(--spacing-l) var(--spacing-xl) var(--spacing-m) |
| var(--spacing-xl); |
| border-bottom: 1px solid var(--border-color); |
| } |
| .headerTopRow, |
| .headerBottomRow { |
| display: flex; |
| justify-content: space-between; |
| align-items: flex-end; |
| } |
| .headerTopRow gr-dropdown-list { |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| padding: 0 var(--spacing-m); |
| } |
| .headerBottomRow { |
| margin-top: var(--spacing-s); |
| } |
| .headerBottomRow .right { |
| display: flex; |
| align-items: center; |
| } |
| .headerBottomRow .links iron-icon { |
| color: var(--link-color); |
| margin-right: var(--spacing-l); |
| } |
| #moreActions iron-icon { |
| color: var(--link-color); |
| } |
| #moreMessage { |
| display: none; |
| } |
| .body { |
| display: block; |
| padding: var(--spacing-s) var(--spacing-xl) var(--spacing-xl) |
| var(--spacing-xl); |
| } |
| .filterDiv { |
| display: flex; |
| margin-top: var(--spacing-s); |
| align-items: center; |
| } |
| .filterDiv input#filterInput { |
| padding: var(--spacing-s) var(--spacing-m); |
| min-width: 400px; |
| } |
| .filterDiv .selection { |
| padding: var(--spacing-s) var(--spacing-m); |
| } |
| .filterDiv iron-icon.filter { |
| color: var(--selected-foreground); |
| } |
| .filterDiv gr-button.reset { |
| margin: calc(0px - var(--spacing-s)) var(--spacing-l); |
| } |
| .categoryHeader { |
| margin-top: var(--spacing-l); |
| margin-left: var(--spacing-l); |
| cursor: default; |
| } |
| .categoryHeader .title { |
| text-transform: capitalize; |
| } |
| .categoryHeader .expandIcon { |
| width: var(--line-height-h3); |
| height: var(--line-height-h3); |
| margin-right: var(--spacing-s); |
| } |
| .categoryHeader .statusIcon { |
| position: relative; |
| top: 2px; |
| } |
| .categoryHeader .statusIcon.error { |
| color: var(--error-foreground); |
| } |
| .categoryHeader .statusIcon.warning { |
| color: var(--warning-foreground); |
| } |
| .categoryHeader .statusIcon.info { |
| color: var(--info-foreground); |
| } |
| .categoryHeader .statusIcon.success { |
| color: var(--success-foreground); |
| } |
| .categoryHeader .filtered { |
| color: var(--deemphasized-text-color); |
| } |
| .collapsed .noResultsMessage, |
| .collapsed table { |
| display: none; |
| } |
| .collapsed { |
| border-bottom: 1px solid var(--border-color); |
| padding-bottom: var(--spacing-m); |
| } |
| .noResultsMessage { |
| width: 100%; |
| max-width: 1280px; |
| margin-top: var(--spacing-m); |
| background-color: var(--background-color-primary); |
| box-shadow: var(--elevation-level-1); |
| padding: var(--spacing-s) |
| calc(20px + var(--spacing-l) + var(--spacing-m) + var(--spacing-s)); |
| } |
| table.resultsTable { |
| width: 100%; |
| max-width: 1280px; |
| margin-top: var(--spacing-m); |
| background-color: var(--background-color-primary); |
| box-shadow: var(--elevation-level-1); |
| } |
| tr.headerRow th { |
| text-align: left; |
| font-weight: var(--font-weight-bold); |
| padding: var(--spacing-s); |
| } |
| gr-button.showAll { |
| margin: var(--spacing-m); |
| } |
| tr { |
| border-top: 1px solid var(--border-color); |
| } |
| `, |
| ]; |
| } |
| |
| protected updated(changedProperties: PropertyValues) { |
| super.updated(changedProperties); |
| if (changedProperties.has('tabState') && this.tabState) { |
| const {statusOrCategory, checkName} = this.tabState; |
| if ( |
| statusOrCategory && |
| statusOrCategory !== RunStatus.RUNNING && |
| statusOrCategory !== RunStatus.RUNNABLE |
| ) { |
| let cat = statusOrCategory.toString().toLowerCase(); |
| if (statusOrCategory === RunStatus.COMPLETED) cat = 'success'; |
| this.scrollElIntoView(`.categoryHeader .${cat}`); |
| } else if (checkName) { |
| this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`); |
| } |
| } |
| } |
| |
| private scrollElIntoView(selector: string) { |
| this.updateComplete.then(() => { |
| let el = this.shadowRoot?.querySelector(selector); |
| // <gr-result-row> has display:contents and cannot be scrolled into view |
| // itself. Thus we are preferring to scroll the first child into view. |
| el = el?.shadowRoot?.firstElementChild ?? el; |
| el?.scrollIntoView({block: 'center'}); |
| }); |
| } |
| |
| render() { |
| return html` |
| <div class="header"> |
| <div class="headerTopRow"> |
| <div class="left"> |
| <h2 class="heading-2">Results</h2> |
| </div> |
| <div class="middle"> |
| <span ?hidden="${!this.someProvidersAreLoading}">Loading...</span> |
| </div> |
| <div class="right"> |
| <gr-dropdown-list |
| value="${this.checksPatchsetNumber}" |
| .items="${this.createPatchsetDropdownItems()}" |
| @value-change="${this.onPatchsetSelected}" |
| ></gr-dropdown-list> |
| </div> |
| </div> |
| <div class="headerBottomRow"> |
| <div class="left">${this.renderFilter()}</div> |
| <div class="right">${this.renderLinks()}${this.renderActions()}</div> |
| </div> |
| </div> |
| <div class="body"> |
| ${this.renderSection(Category.ERROR)} |
| ${this.renderSection(Category.WARNING)} |
| ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')} |
| </div> |
| `; |
| } |
| |
| private renderLinks() { |
| const links = (this.links ?? []).slice(0, 4); |
| if (links.length === 0) return; |
| return html`<div class="links">${links.map(this.renderLink)}</div>`; |
| } |
| |
| private renderLink(link: Link) { |
| const tooltipText = link.tooltip ?? tooltipForLink(link.icon); |
| return html`<a href="${link.url}" target="_blank" |
| ><iron-icon |
| aria-label="${tooltipText}" |
| class="link" |
| icon="gr-icons:${iconForLink(link.icon)}" |
| ></iron-icon |
| ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a |
| >`; |
| } |
| |
| private renderActions() { |
| const overflowItems = this.actions.slice(2).map(action => { |
| return {...action, id: action.name}; |
| }); |
| return html` |
| ${this.renderAction(this.actions[0])} |
| ${this.renderAction(this.actions[1])} |
| <gr-dropdown |
| id="moreActions" |
| link="" |
| vertical-offset="32" |
| horizontal-align="right" |
| @tap-item="${this.handleAction}" |
| ?hidden="${overflowItems.length === 0}" |
| .items="${overflowItems}" |
| > |
| <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage"> |
| </iron-icon> |
| <span id="moreMessage">More</span> |
| </gr-dropdown> |
| `; |
| } |
| |
| private handleAction(e: CustomEvent<Action>) { |
| fireActionTriggered(this, e.detail); |
| } |
| |
| private renderAction(action?: Action) { |
| if (!action) return; |
| return html`<gr-checks-action .action="${action}"></gr-checks-action>`; |
| } |
| |
| private onPatchsetSelected(e: CustomEvent<{value: string}>) { |
| const patchset = Number(e.detail.value); |
| check(!isNaN(patchset), 'selected patchset must be a number'); |
| this.checksService.setPatchset(patchset as PatchSetNumber); |
| } |
| |
| private createPatchsetDropdownItems() { |
| if (!this.latestPatchsetNumber) return []; |
| return Array.from(Array(this.latestPatchsetNumber), (_, i) => { |
| assertIsDefined(this.latestPatchsetNumber, 'latestPatchsetNumber'); |
| const index = this.latestPatchsetNumber - i; |
| const postfix = index === this.latestPatchsetNumber ? ' (latest)' : ''; |
| return { |
| value: `${index}`, |
| text: `Patchset ${index}${postfix}`, |
| }; |
| }); |
| } |
| |
| isRunSelected(run: {checkName: string}) { |
| return ( |
| this.selectedRuns.length === 0 || |
| this.selectedRuns.includes(run.checkName) |
| ); |
| } |
| |
| renderFilter() { |
| const runs = this.runs.filter( |
| run => |
| this.isRunSelected(run) && isAttemptSelected(this.selectedAttempts, run) |
| ); |
| if (this.selectedRuns.length === 0 && allResults(runs).length <= 3) { |
| if (this.filterRegExp.source.length > 0) { |
| this.filterRegExp = new RegExp(''); |
| } |
| return; |
| } |
| return html` |
| <div class="filterDiv"> |
| <input |
| id="filterInput" |
| type="text" |
| placeholder="Filter results by regular expression" |
| @input="${this.onInput}" |
| /> |
| <div class="selection">${this.renderSelectionFilter()}</div> |
| </div> |
| `; |
| } |
| |
| renderSelectionFilter() { |
| const count = this.selectedRuns.length; |
| if (count === 0) return; |
| return html` |
| <iron-icon class="filter" icon="gr-icons:filter"></iron-icon> |
| <span>Filtered by ${pluralize(count, 'run')}</span> |
| <gr-button link class="reset" @click="${this.handleClick}" |
| >Reset View</gr-button |
| > |
| `; |
| } |
| |
| handleClick() { |
| this.filterRegExp = new RegExp(''); |
| fireRunSelectionReset(this); |
| } |
| |
| onInput() { |
| assertIsDefined(this.filterInput, 'filter <input> element'); |
| this.filterRegExp = new RegExp(this.filterInput.value, 'i'); |
| } |
| |
| renderSection(category: Category | 'SUCCESS') { |
| const catString = category.toString().toLowerCase(); |
| let allRuns = this.runs.filter(run => |
| isAttemptSelected(this.selectedAttempts, run) |
| ); |
| if (category === 'SUCCESS') { |
| allRuns = allRuns.filter(hasCompletedWithoutResults); |
| } else { |
| allRuns = allRuns.filter(r => hasResultsOf(r, category)); |
| } |
| const all = allRuns.reduce( |
| (results: RunResult[], run) => [ |
| ...results, |
| ...this.computeRunResults(category, run), |
| ], |
| [] |
| ); |
| const selected = all.filter(result => this.isRunSelected(result)); |
| const filtered = selected.filter( |
| result => |
| this.filterRegExp.test(result.checkName) || |
| this.filterRegExp.test(result.summary) |
| ); |
| let expanded = this.isSectionExpanded.get(category); |
| const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false; |
| if (!expandedByUser || expanded === undefined) { |
| expanded = selected.length > 0; |
| this.isSectionExpanded.set(category, expanded); |
| } |
| const expandedClass = expanded ? 'expanded' : 'collapsed'; |
| const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more'; |
| const isShowAll = this.isShowAll.get(category) ?? false; |
| const showAllThreshold = SHOW_ALL_THRESHOLDS.get(category) ?? 5; |
| const resultCount = filtered.length; |
| const resultLimit = isShowAll ? 1000 : showAllThreshold; |
| const showAllButton = this.renderShowAllButton( |
| category, |
| isShowAll, |
| showAllThreshold, |
| resultCount |
| ); |
| return html` |
| <div class="${expandedClass}"> |
| <h3 |
| class="categoryHeader ${catString} heading-3" |
| @click="${() => this.toggleExpanded(category)}" |
| > |
| <iron-icon class="expandIcon" icon="${icon}"></iron-icon> |
| <iron-icon |
| icon="gr-icons:${iconForCategory(category)}" |
| class="statusIcon ${catString}" |
| ></iron-icon> |
| <span class="title">${catString}</span> |
| <span class="count" |
| >${this.renderCount(all, selected, filtered)}</span |
| > |
| </h3> |
| ${this.renderResults( |
| all, |
| selected, |
| filtered, |
| resultLimit, |
| showAllButton |
| )} |
| </div> |
| `; |
| } |
| |
| renderShowAllButton( |
| category: Category | 'SUCCESS', |
| isShowAll: boolean, |
| showAllThreshold: number, |
| resultCount: number |
| ) { |
| if (resultCount <= showAllThreshold) return; |
| const message = isShowAll ? 'Show Less' : `Show All (${resultCount})`; |
| const handler = () => this.toggleShowAll(category); |
| return html` |
| <tr class="showAllRow"> |
| <td colspan="4"> |
| <gr-button class="showAll" link @click="${handler}" |
| >${message}</gr-button |
| > |
| </td> |
| </tr> |
| `; |
| } |
| |
| toggleShowAll(category: Category | 'SUCCESS') { |
| const current = this.isShowAll.get(category) ?? false; |
| this.isShowAll.set(category, !current); |
| this.requestUpdate(); |
| } |
| |
| renderResults( |
| all: RunResult[], |
| selected: RunResult[], |
| filtered: RunResult[], |
| limit: number, |
| showAll: TemplateResult | undefined |
| ) { |
| if (all.length === 0) { |
| return html`<div class="noResultsMessage">No results</div>`; |
| } |
| if (selected.length === 0) { |
| return html`<div class="noResultsMessage"> |
| No results for this filtered view |
| </div>`; |
| } |
| if (filtered.length === 0) { |
| return html`<div class="noResultsMessage"> |
| No results match the regular expression |
| </div>`; |
| } |
| filtered = filtered.slice(0, limit); |
| return html` |
| <table class="resultsTable"> |
| <thead> |
| <tr class="headerRow"> |
| <th class="iconCol"></th> |
| <th class="nameCol">Run</th> |
| <th class="summaryCol">Summary</th> |
| <th class="expanderCol"></th> |
| </tr> |
| </thead> |
| <tbody> |
| ${repeat( |
| filtered, |
| result => result.internalResultId, |
| result => html` |
| <gr-result-row |
| class="${charsOnly(result.checkName)}" |
| .result="${result}" |
| ></gr-result-row> |
| ` |
| )} |
| ${showAll} |
| </tbody> |
| </table> |
| `; |
| } |
| |
| renderCount(all: RunResult[], selected: RunResult[], filtered: RunResult[]) { |
| if (all.length === filtered.length) { |
| return html`(${all.length})`; |
| } |
| if (all.length !== selected.length) { |
| return html`<span class="filtered"> - filtered</span>`; |
| } |
| return html`(${filtered.length} of ${all.length})`; |
| } |
| |
| toggleExpanded(category: Category | 'SUCCESS') { |
| const expanded = this.isSectionExpanded.get(category); |
| assertIsDefined(expanded, 'expanded must have been set in initial render'); |
| this.isSectionExpanded.set(category, !expanded); |
| this.isSectionExpandedByUser.set(category, true); |
| this.requestUpdate(); |
| } |
| |
| computeRunResults(category: Category | 'SUCCESS', run: CheckRun) { |
| if (category === 'SUCCESS') return [this.computeSuccessfulRunResult(run)]; |
| return ( |
| run.results |
| ?.filter(result => result.category === category) |
| .map(result => { |
| return {...run, ...result}; |
| }) ?? [] |
| ); |
| } |
| |
| computeSuccessfulRunResult(run: CheckRun): RunResult { |
| const adaptedRun: RunResult = { |
| internalResultId: run.internalRunId + '-0', |
| category: Category.INFO, // will not be used, but is required |
| summary: run.statusDescription ?? '', |
| ...run, |
| }; |
| if (!run.statusDescription) { |
| const start = run.scheduledTimestamp ?? run.startedTimestamp; |
| const end = run.finishedTimestamp; |
| let duration = ''; |
| if (start && end) { |
| duration = ` in ${durationString(start, end, true)}`; |
| } |
| adaptedRun.message = `Completed without results${duration}.`; |
| } |
| if (run.statusLink) { |
| adaptedRun.links = [ |
| { |
| url: run.statusLink, |
| primary: true, |
| icon: LinkIcon.EXTERNAL, |
| }, |
| ]; |
| } |
| return adaptedRun; |
| } |
| } |
| |
| @customElement('gr-checks-action') |
| export class GrChecksAction extends GrLitElement { |
| @property() |
| action!: Action; |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| checkRequiredProperty(this.action, 'action'); |
| } |
| |
| static get styles() { |
| return [ |
| css` |
| :host { |
| display: inline-block; |
| } |
| gr-button { |
| --padding: var(--spacing-s) var(--spacing-m); |
| } |
| gr-button paper-tooltip { |
| text-transform: none; |
| } |
| `, |
| ]; |
| } |
| |
| render() { |
| return html` |
| <gr-button link class="action" @click="${this.handleClick}"> |
| ${this.action.name} |
| <paper-tooltip ?hidden="${!this.action.tooltip}" offset="5" |
| >${this.action.tooltip}</paper-tooltip |
| > |
| </gr-button> |
| `; |
| } |
| |
| handleClick() { |
| fireActionTriggered(this, this.action); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-result-row': GrResultRow; |
| 'gr-result-expanded': GrResultExpanded; |
| 'gr-checks-results': GrChecksResults; |
| 'gr-checks-action': GrChecksAction; |
| } |
| } |