blob: 163b14814e117c11db61713f494fb5e2f28e197f [file] [log] [blame]
// 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 { consumeConfigsStore, UserConfigsStore } from '../../context/user_configs';
import { VARIANT_STATUS_CLASS_MAP } from '../../libs/constants';
import { consumer } from '../../libs/context';
import { reportErrorAsync } from '../../libs/error_handler';
import { createTVPropGetter, getPropKeyLabel, TestVariantStatus } from '../../services/resultdb';
import colorClasses from '../../styles/color_classes.css';
import commonStyle from '../../styles/common_style.css';
import { MiloBaseElement } from '../milo_base';
import { consumeTestVariantTableState, TestVariantTableState, VariantGroup } from './context';
import { TestVariantEntryElement } from './test_variant_entry';
/**
* 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 @consumeTestVariantTableState() tableState!: TestVariantTableState;
@observable.ref hideTestName = false;
@observable.ref showTimestamp = false;
@computed private get columnGetters() {
return this.tableState.columnKeys.map((col) => createTVPropGetter(col));
}
@computed private get columnWidths() {
if (this.hideTestName && this.tableState.columnWidths.length > 0) {
const ret = this.tableState.columnWidths.slice();
ret.pop();
return ret;
}
return this.tableState.columnWidths;
}
private getTvtColumns(columnWidths: readonly number[]) {
return (
'24px ' + (this.showTimestamp ? '135px ' : '') + columnWidths.map((width) => width + 'px').join(' ') + ' 1fr'
);
}
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.tableState.loadedFirstPage,
() => {
if (this.tableState.loadedFirstPage) {
return;
}
reportErrorAsync(this, () => this.tableState.loadFirstPage())();
},
{ fireImmediately: true }
)
);
// Sync column width from the user config.
this.addDisposer(
reaction(
() => this.configsStore.userConfigs.testResults.columnWidths,
(columnWidths) => this.tableState.setColumnWidths(columnWidths),
{ fireImmediately: true }
)
);
}
private loadMore = reportErrorAsync(this, () => this.tableState.loadNextPage());
private renderAllVariants() {
return html`
${this.tableState.variantGroups.map((group) => this.renderVariantGroup(group))}
<div id="variant-list-tail">
${this.tableState.testVariantCount === this.tableState.unfilteredTestVariantCount
? html`
Showing ${this.tableState.testVariantCount} /
${this.tableState.unfilteredTestVariantCount}${this.tableState.loadedAllTestVariants ? '' : '+'} tests.
`
: html`
Showing
<i>${this.tableState.testVariantCount}</i>
test${this.tableState.testVariantCount === 1 ? '' : 's'} that
<i>match${this.tableState.testVariantCount === 1 ? 'es' : ''} the filter</i>, out of
<i>${this.tableState.unfilteredTestVariantCount}${this.tableState.loadedAllTestVariants ? '' : '+'}</i>
tests.
`}
<span
class="active-text"
style=${styleMap({
display: !this.tableState.loadedAllTestVariants && this.tableState.readyToLoad ? '' : 'none',
})}
>${this.renderLoadMore()}</span
>
</div>
`;
}
@observable private collapsedVariantGroups = new Set<string>();
private renderVariantGroup(group: VariantGroup) {
if (!this.tableState.enablesGrouping) {
return repeat(
group.variants,
(v) => `${v.testId} ${v.variantHash}`,
(v) => html`
<milo-test-variant-entry
.variant=${v}
.columnGetters=${this.columnGetters}
.expanded=${this.tableState.testVariantCount === 1}
.hideTestName=${this.hideTestName}
.showTimestamp=${this.showTimestamp}
.historyUrl=${this.tableState.getHistoryUrl(v.testId, v.variantHash)}
></milo-test-variant-entry>
`
);
}
const groupId = JSON.stringify(group.def);
const expanded = !this.collapsedVariantGroups.has(groupId);
return html`
<div
class=${classMap({
expanded,
empty: group.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>${group.variants.length} test variant${group.variants.length === 1 ? '' : 's'}:</b>
${group.def.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
>`
)}
${group.note || ''}
</div>
</div>
${repeat(
expanded ? group.variants : [],
(v) => `${v.testId} ${v.variantHash}`,
(v) => html`
<milo-test-variant-entry
.variant=${v}
.columnGetters=${this.columnGetters}
.expanded=${this.tableState.testVariantCount === 1}
.hideTestName=${this.hideTestName}
.showTimestamp=${this.showTimestamp}
.historyUrl=${this.tableState.getHistoryUrl(v.testId, v.variantHash)}
></milo-test-variant-entry>
`
)}
`;
}
private renderLoadMore() {
const state = this.tableState;
return html`
<span style=${styleMap({ display: state.isLoading ?? true ? 'none' : '' })} @click=${() => this.loadMore()}>
[load more]
</span>
<span
style=${styleMap({
display: state.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 style="--tvt-columns: ${this.getTvtColumns(this.columnWidths)}">
<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.showTimestamp
? html`
<milo-tvt-column-header
.propKey=${'partitionTime'}
.label=${'Timestamp'}
.canHide=${false}
></milo-tvt-column-header>
`
: ''}
${this.tableState.columnKeys.map(
(col, i) => html`<milo-tvt-column-header
.colIndex=${
// When hiding test name, don't make the last column resizable.
this.tableState.columnKeys.length - 1 === i && this.hideTestName ? undefined : i
}
.resizeTo=${(newWidth: number, finalized: boolean) => {
if (!finalized) {
const newColWidths = this.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('--tvt-columns', this.getTvtColumns(newColWidths));
return;
}
this.tableHeaderEle?.style.removeProperty('--tvt-columns');
this.configsStore.userConfigs.testResults.columnWidths[col] = newWidth;
}}
.propKey=${col}
.label=${getPropKeyLabel(col)}
></milo-tvt-column-header>`
)}
${this.hideTestName
? ''
: html`
<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>
</div>
`;
}
static styles = [
commonStyle,
colorClasses,
css`
:host {
display: block;
--tvt-top-offset: 0px;
}
#table-header {
display: grid;
grid-template-columns: 24px var(--tvt-columns);
grid-gap: 5px;
line-height: 24px;
padding: 2px 2px 2px 10px;
font-weight: bold;
position: sticky;
top: var(--tvt-top-offset);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
background-color: var(--block-background-color);
z-index: 2;
}
#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: calc(var(--tvt-top-offset) + 29px);
font-size: 14px;
background-color: var(--block-background-color);
border-top: 1px solid var(--divider-color);
cursor: pointer;
line-height: 24px;
z-index: 1;
}
.group-header:first-child {
top: calc(var(--tvt-top-offset) + 30px);
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);
}
`,
];
}