| // Copyright 2021 The LUCI Authors. |
| // |
| // 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 '@material/mwc-button'; |
| import '@material/mwc-icon'; |
| import { css, customElement, html } from 'lit-element'; |
| import { classMap } from 'lit-html/directives/class-map'; |
| import { repeat } from 'lit-html/directives/repeat'; |
| import { styleMap } from 'lit-html/directives/style-map'; |
| import { computed, observable, reaction } from 'mobx'; |
| |
| import '../dot_spinner'; |
| import './tvt_column_header'; |
| import './test_variant_entry'; |
| import { AppState, consumeAppState } from '../../context/app_state'; |
| import { consumeInvocationState, InvocationState } from '../../context/invocation_state'; |
| import { consumeConfigsStore, UserConfigsStore } from '../../context/user_configs'; |
| import { GA_ACTIONS, GA_CATEGORIES, generateRandomLabel, trackEvent } from '../../libs/analytics_utils'; |
| import { VARIANT_STATUS_CLASS_MAP } from '../../libs/constants'; |
| import { consumer } from '../../libs/context'; |
| import { reportErrorAsync } from '../../libs/error_handler'; |
| import { TestVariant, TestVariantStatus } from '../../services/resultdb'; |
| import colorClasses from '../../styles/color_classes.css'; |
| import commonStyle from '../../styles/common_style.css'; |
| import { MiloBaseElement } from '../milo_base'; |
| import { TestVariantEntryElement } from './test_variant_entry'; |
| |
| function getPropKeyLabel(key: string) { |
| // If the key has the format of '{type}.{value}', hide the '{type}.' prefix. |
| return key.split('.', 2)[1] ?? key; |
| } |
| |
| /** |
| * Displays test variants in a table. |
| */ |
| @customElement('milo-test-variants-table') |
| @consumer |
| export class TestVariantsTableElement extends MiloBaseElement { |
| @observable.ref @consumeAppState appState!: AppState; |
| @observable.ref @consumeConfigsStore configsStore!: UserConfigsStore; |
| @observable.ref @consumeInvocationState invocationState!: InvocationState; |
| |
| toggleAllVariants(expand: boolean) { |
| this.shadowRoot!.querySelectorAll<TestVariantEntryElement>('milo-test-variant-entry').forEach( |
| (e) => (e.expanded = expand) |
| ); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| |
| // When a new test loader is received, load the first page. |
| this.addDisposer( |
| reaction( |
| () => this.invocationState.testLoader, |
| (testLoader) => reportErrorAsync.bind(this)(async () => testLoader?.loadFirstPageOfTestVariants())(), |
| { fireImmediately: true } |
| ) |
| ); |
| } |
| |
| private loadMore = reportErrorAsync.bind(this)(async () => this.invocationState.testLoader?.loadNextTestVariants()); |
| |
| private renderAllVariants() { |
| const testLoader = this.invocationState.testLoader; |
| const groupers = this.invocationState.groupers; |
| return html` |
| ${ |
| // Indicates that there are no unexpected test variants. |
| testLoader?.loadedAllUnexpectedVariants && testLoader.unexpectedTestVariants.length === 0 |
| ? this.renderVariantGroup([['status', TestVariantStatus.UNEXPECTED]], []) |
| : '' |
| } |
| ${(testLoader?.groupedNonExpectedVariants || []).map((group) => |
| this.renderVariantGroup( |
| groupers.map(([key, getter]) => [key, getter(group[0])]), |
| group |
| ) |
| )} |
| ${this.renderVariantGroup([['status', TestVariantStatus.EXPECTED]], testLoader?.expectedTestVariants || [])} |
| <div id="variant-list-tail"> |
| ${testLoader?.testVariantCount === testLoader?.unfilteredTestVariantCount |
| ? html` |
| Showing ${testLoader?.testVariantCount || 0} / |
| ${testLoader?.unfilteredTestVariantCount || 0}${testLoader?.loadedAllVariants ? '' : '+'} tests. |
| ` |
| : html` |
| Showing |
| <i>${testLoader?.testVariantCount || 0}</i> |
| test${testLoader?.testVariantCount === 1 ? '' : 's'} that |
| <i>match${testLoader?.testVariantCount === 1 ? 'es' : ''} the filter</i>, out of |
| <i>${testLoader?.unfilteredTestVariantCount || 0}${testLoader?.loadedAllVariants ? '' : '+'}</i> tests. |
| `} |
| <span |
| class="active-text" |
| style=${styleMap({ display: this.invocationState.testLoader?.loadedAllVariants ?? true ? 'none' : '' })} |
| >${this.renderLoadMore()}</span |
| > |
| </div> |
| `; |
| } |
| |
| private sendLoadingTimeToGA = () => { |
| if (this.appState.sentTestResultsTabLoadingTimeToGA) { |
| return; |
| } |
| this.appState.sentTestResultsTabLoadingTimeToGA = true; |
| trackEvent( |
| GA_CATEGORIES.TEST_RESULTS_TAB, |
| GA_ACTIONS.LOADING_TIME, |
| generateRandomLabel(this.gaLabelPrefix), |
| Date.now() - this.appState.tabSelectionTime |
| ); |
| }; |
| |
| @computed private get gaLabelPrefix() { |
| return 'testresults_' + this.invocationState.invocationId; |
| } |
| |
| private variantExpandedCallback = () => { |
| trackEvent(GA_CATEGORIES.TEST_RESULTS_TAB, GA_ACTIONS.EXPAND_ENTRY, `${this.gaLabelPrefix}_${VISIT_ID}`, 1); |
| }; |
| |
| @observable private collapsedVariantGroups = new Set<string>(); |
| private renderVariantGroup(groupDef: [string, unknown][], variants: TestVariant[]) { |
| const groupId = JSON.stringify(groupDef); |
| const expanded = !this.collapsedVariantGroups.has(groupId); |
| return html` |
| <div |
| class=${classMap({ |
| expanded, |
| empty: variants.length === 0, |
| 'group-header': true, |
| })} |
| @click=${() => { |
| if (expanded) { |
| this.collapsedVariantGroups.add(groupId); |
| } else { |
| this.collapsedVariantGroups.delete(groupId); |
| } |
| }} |
| > |
| <mwc-icon class="group-icon">${expanded ? 'expand_more' : 'chevron_right'}</mwc-icon> |
| <div> |
| <b>${variants.length} test variant${variants.length === 1 ? '' : 's'}:</b> |
| ${groupDef.map( |
| ([k, v]) => |
| html`<span class="group-kv" |
| ><span>${getPropKeyLabel(k)}=</span |
| ><span class=${k === 'status' ? VARIANT_STATUS_CLASS_MAP[v as TestVariantStatus] : ''}>${v}</span></span |
| >` |
| )} |
| </div> |
| </div> |
| ${repeat( |
| expanded ? variants : [], |
| (v) => `${v.testId} ${v.variantHash}`, |
| (v) => html` |
| <milo-test-variant-entry |
| .variant=${v} |
| .columnGetters=${this.invocationState.displayedColumnGetters} |
| .expanded=${this.invocationState.testLoader?.testVariantCount === 1} |
| .expandedCallback=${this.variantExpandedCallback} |
| .renderedCallback=${this.sendLoadingTimeToGA} |
| ></milo-test-variant-entry> |
| ` |
| )} |
| `; |
| } |
| |
| private renderLoadMore() { |
| const state = this.invocationState; |
| return html` |
| <span |
| style=${styleMap({ display: state.testLoader?.isLoading ?? true ? 'none' : '' })} |
| @click=${() => this.loadMore()} |
| > |
| [load more] |
| </span> |
| <span |
| style=${styleMap({ |
| display: state.testLoader?.isLoading ?? true ? '' : 'none', |
| cursor: 'initial', |
| })} |
| > |
| loading <milo-dot-spinner></milo-dot-spinner> |
| </span> |
| `; |
| } |
| |
| private tableHeaderEle?: HTMLElement; |
| protected updated() { |
| this.tableHeaderEle = this.shadowRoot!.getElementById('table-header')!; |
| } |
| |
| protected render() { |
| return html` |
| <div id="table-header"> |
| <div><!-- Expand toggle --></div> |
| <milo-tvt-column-header |
| .propKey=${'status'} |
| .label=${/* invis char */ '\u2002' + 'S'} |
| .canHide=${false} |
| ></milo-tvt-column-header> |
| ${this.invocationState.displayedColumns.map( |
| (col, i) => html`<milo-tvt-column-header |
| .colIndex=${i} |
| .resizeTo=${(newWidth: number, finalized: boolean) => { |
| if (!finalized) { |
| const newColWidths = this.invocationState.columnWidths.slice(); |
| newColWidths[i] = newWidth; |
| // Update the style directly so lit-element doesn't need to |
| // re-render the component frequently. |
| // Live updating the width of the entire column can cause a bit |
| // of lag when there are many rows. Live updating just the |
| // column header is good enough. |
| this.tableHeaderEle?.style.setProperty('--columns', newColWidths.map((w) => w + 'px').join(' ')); |
| return; |
| } |
| |
| this.tableHeaderEle?.style.removeProperty('--columns'); |
| this.configsStore.userConfigs.testResults.columnWidths[col] = newWidth; |
| }} |
| .propKey=${col} |
| .label=${getPropKeyLabel(col)} |
| ></milo-tvt-column-header>` |
| )} |
| <milo-tvt-column-header .propKey=${'name'} .label=${'Name'} .canHide=${false} .canGroup=${false}> |
| </milo-tvt-column-header> |
| </div> |
| <div id="test-variant-list" tabindex="0">${this.renderAllVariants()}</div> |
| `; |
| } |
| |
| static styles = [ |
| commonStyle, |
| colorClasses, |
| css` |
| #table-header { |
| display: grid; |
| grid-template-columns: 24px 24px var(--columns) 1fr; |
| grid-gap: 5px; |
| line-height: 24px; |
| padding: 2px 2px 2px 10px; |
| font-weight: bold; |
| position: sticky; |
| top: 39px; |
| border-bottom: 1px solid var(--divider-color); |
| background-color: var(--block-background-color); |
| z-index: 1; |
| } |
| |
| #no-invocation { |
| padding: 10px; |
| } |
| #test-variant-list > * { |
| padding-left: 10px; |
| } |
| milo-test-variant-entry { |
| margin: 2px 0px; |
| } |
| |
| #integration-hint { |
| border-bottom: 1px solid var(--divider-color); |
| padding: 0 0 5px 15px; |
| } |
| |
| .group-header { |
| display: grid; |
| grid-template-columns: auto auto 1fr; |
| grid-gap: 5px; |
| padding: 2px 2px 2px 10px; |
| position: sticky; |
| top: 67px; |
| font-size: 14px; |
| background-color: var(--block-background-color); |
| border-top: 1px solid var(--divider-color); |
| cursor: pointer; |
| user-select: none; |
| line-height: 24px; |
| z-index: 1; |
| } |
| .group-header:first-child { |
| top: 68px; |
| border-top: none; |
| } |
| .group-header.expanded:not(.empty) { |
| border-bottom: 1px solid var(--divider-color); |
| } |
| .group-kv:not(:last-child)::after { |
| content: ', '; |
| } |
| .group-kv > span:first-child { |
| color: var(--light-text-color); |
| } |
| .group-kv > span:nth-child(2) { |
| font-weight: 500; |
| font-style: italic; |
| } |
| |
| .inline-icon { |
| --mdc-icon-size: 1.2em; |
| vertical-align: bottom; |
| } |
| |
| #variant-list-tail { |
| padding: 5px 0 5px 15px; |
| } |
| #variant-list-tail:not(:first-child) { |
| border-top: 1px solid var(--divider-color); |
| } |
| #load { |
| color: var(--active-text-color); |
| } |
| `, |
| ]; |
| } |