| /** |
| * @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 { |
| css, |
| customElement, |
| internalProperty, |
| property, |
| PropertyValues, |
| query, |
| } from 'lit-element'; |
| import {GrLitElement} from '../lit/gr-lit-element'; |
| import {Category, CheckRun, Link, RunStatus, Tag} from '../../api/checks'; |
| import {sharedStyles} from '../../styles/shared-styles'; |
| import {RunResult} from '../../services/checks/checks-model'; |
| import { |
| hasCompleted, |
| hasCompletedWithoutResults, |
| iconForCategory, |
| isRunning, |
| } from '../../services/checks/checks-util'; |
| import {assertIsDefined} from '../../utils/common-util'; |
| import {whenVisible} from '../../utils/dom-util'; |
| |
| @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; |
| } |
| tr { |
| border-top: 1px solid var(--border-color); |
| } |
| iron-icon.launch { |
| color: var(--link-color); |
| margin-right: var(--spacing-s); |
| } |
| 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; |
| } |
| .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 - 700px); |
| } |
| 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; |
| } |
| td .summary-cell .tags .tag { |
| color: var(--deemphasized-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(--deemphasized-text-color); |
| display: inline-block; |
| border-radius: 20px; |
| background-color: var(--label-background); |
| padding: 0 var(--spacing-m); |
| margin-left: var(--spacing-s); |
| } |
| `, |
| ]; |
| } |
| |
| 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="container" @click="${this.toggleExpanded}"> |
| <td class="iconCol"> |
| <div>${this.renderIcon()}</div> |
| </td> |
| <td class="nameCol"> |
| <div><span>${this.result.checkName}</span></div> |
| </td> |
| <td class="summaryCol"> |
| <div class="summary-cell"> |
| ${(this.result.links?.slice(0, 5) ?? []).map(this.renderLink)} |
| ${this.renderSummary(this.result.summary)} |
| <div class="message"> |
| ${this.isExpanded ? '' : this.result.message} |
| </div> |
| <div class="tags"> |
| ${(this.result.tags ?? []).map(t => this.renderTag(t))} |
| </div> |
| ${this.renderLabel()} |
| </div> |
| <gr-result-expanded |
| .result="${this.result}" |
| ?hidden="${!this.isExpanded}" |
| ></gr-result-expanded> |
| </td> |
| <td class="expanderCol"> |
| <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 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">${text} </div> |
| `; |
| } |
| |
| renderLink(link: Link) { |
| return html` |
| <a href="${link.url}" target="_blank"> |
| <iron-icon |
| aria-label="external link to details" |
| class="launch" |
| icon="gr-icons:launch" |
| ></iron-icon> |
| </a> |
| `; |
| } |
| |
| 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>`; |
| } |
| |
| renderTag(tag: Tag) { |
| return html`<div class="tag">${tag.name}</div>`; |
| } |
| } |
| |
| @customElement('gr-result-expanded') |
| class GrResultExpanded extends GrLitElement { |
| @property() |
| result?: RunResult; |
| |
| static get styles() { |
| return [ |
| sharedStyles, |
| css` |
| .message { |
| padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0; |
| } |
| `, |
| ]; |
| } |
| |
| 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> |
| <div class="message"> |
| ${this.result.message} |
| </div> |
| </gr-endpoint-decorator> |
| `; |
| } |
| } |
| |
| @customElement('gr-checks-results') |
| export class GrChecksResults extends GrLitElement { |
| @query('#filterInput') |
| filterInput?: HTMLInputElement; |
| |
| @internalProperty() |
| filterRegExp = new RegExp(''); |
| |
| @property() |
| runs: CheckRun[] = []; |
| |
| private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>(); |
| |
| static get styles() { |
| return [ |
| sharedStyles, |
| css` |
| :host { |
| display: block; |
| padding: var(--spacing-xl); |
| } |
| input#filterInput { |
| margin-top: var(--spacing-s); |
| padding: var(--spacing-s) var(--spacing-m); |
| min-width: 400px; |
| } |
| .categoryHeader { |
| margin-top: var(--spacing-l); |
| margin-left: var(--spacing-l); |
| text-transform: capitalize; |
| cursor: default; |
| } |
| .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); |
| } |
| .collapsed table { |
| display: none; |
| } |
| .collapsed { |
| border-bottom: 1px solid var(--border-color); |
| padding-bottom: var(--spacing-m); |
| } |
| .noCompleted { |
| margin-top: var(--spacing-l); |
| } |
| 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); |
| } |
| `, |
| ]; |
| } |
| |
| render() { |
| return html` |
| <div><h2 class="heading-2">Results</h2></div> |
| ${this.renderFilter()} ${this.renderNoCompleted()} |
| ${this.renderSection(Category.ERROR)} |
| ${this.renderSection(Category.WARNING)} |
| ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')} |
| `; |
| } |
| |
| renderFilter() { |
| if (this.runs.length === 0) return; |
| return html` |
| <input |
| id="filterInput" |
| type="text" |
| placeholder="Filter results by regular expression" |
| @input="${this.onInput}" |
| /> |
| `; |
| } |
| |
| onInput() { |
| assertIsDefined(this.filterInput, 'filter <input> element'); |
| this.filterRegExp = new RegExp(this.filterInput.value, 'i'); |
| } |
| |
| renderNoCompleted() { |
| if (this.runs.some(hasCompleted)) return; |
| let text = 'No results'; |
| if (this.runs.some(isRunning)) { |
| text = 'Checks are running ...'; |
| } |
| return html`<div class="noCompleted">${text}</div>`; |
| } |
| |
| renderSection(category: Category | 'SUCCESS') { |
| const catString = category.toString().toLowerCase(); |
| let runs = this.runs; |
| if (category === 'SUCCESS') { |
| runs = runs |
| .filter(hasCompletedWithoutResults) |
| .filter(r => this.filterRegExp.test(r.checkName)); |
| } else { |
| runs = runs.filter(r => |
| (r.results ?? []).some(res => res.category === category) |
| ); |
| } |
| if (runs.length === 0) return; |
| const expanded = this.isSectionExpanded.get(category) ?? true; |
| const expandedClass = expanded ? 'expanded' : 'collapsed'; |
| const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less'; |
| return html` |
| <div class="${expandedClass}"> |
| <h3 |
| class="categoryHeader 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> |
| ${catString} |
| </h3> |
| <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> |
| ${runs.map(run => |
| category === 'SUCCESS' |
| ? this.renderSuccessfulRun(run) |
| : this.renderRun(category, run) |
| )} |
| </tbody> |
| </table> |
| </div> |
| `; |
| } |
| |
| toggleExpanded(category: Category | 'SUCCESS') { |
| const expanded = this.isSectionExpanded.get(category) ?? true; |
| this.isSectionExpanded.set(category, !expanded); |
| this.requestUpdate(); |
| } |
| |
| renderRun(category: Category, run: CheckRun) { |
| return html`${run.results |
| ?.filter(result => result.category === category) |
| .filter( |
| result => |
| this.filterRegExp.test(run.checkName) || |
| this.filterRegExp.test(result.summary) |
| ) |
| .map( |
| result => |
| html`<gr-result-row .result="${{...run, ...result}}"></gr-result-row>` |
| )}`; |
| } |
| |
| renderSuccessfulRun(run: CheckRun) { |
| const adaptedRun: RunResult = { |
| category: Category.INFO, // will not be used, but is required |
| summary: run.statusDescription ?? '', |
| message: 'Completed without results.', |
| ...run, |
| }; |
| return html`<gr-result-row .result="${adaptedRun}"></gr-result-row>`; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-result-row': GrResultRow; |
| 'gr-result-expanded': GrResultExpanded; |
| 'gr-checks-results': GrChecksResults; |
| } |
| } |