blob: a536e543e2ce9bf2f7092c6818397779e198bca1 [file] [log] [blame]
/**
* @module modules/test-picker-sk
* @description <h2><code>test-picker-sk</code></h2>
*
* A trace/test picker used to select a valid trace.
* This element will guide the user by providing the following:
* - Specific order in which fields must be filled.
* - Fields with dropdown menus to aid in selecting valid values for each param.
* - Indicator as to when a test is ready to be plotted.
*
* This Element also provides the option to populate all the fields
* using a given query. e.g.:
*
* populateFieldDataFromQuery(
* 'benchmark=a&bot=b&test=c&subtest1=&subtest2=d',
* ['benchmark', 'bot', 'test', 'subtest1', 'subtest2']
* )
*
* In the above case, fields will be filled in order of hierarchy until an
* empty value is reached. Since subtest1 is empty, it'll stop filling at
* subtest1, leaving subtest1 and subtest2 fields empty.
*
* @evt plot-button-clicked - Triggered when the Plot button is clicked.
* It will contain the currently populated query in the test picker in
* event.detail.query.
*
*/
import { html } from 'lit/html.js';
import { define } from '../../../elements-sk/modules/define';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { ParamSet, fromParamSet, toParamSet } from '../../../infra-sk/modules/query';
import { NextParamListHandlerRequest, NextParamListHandlerResponse } from '../json';
import '../picker-field-sk';
import { PickerFieldSk } from '../picker-field-sk/picker-field-sk';
import { errorMessage } from '../../../elements-sk/modules/errorMessage';
import '../../../elements-sk/modules/spinner-sk';
// The maximum number of matches before Plotting is enabled.
const PLOT_MAXIMUM = 10;
// Data Structure to keep track of field information.
class FieldInfo {
field: PickerFieldSk | null = null; // The field element itself.
param: string = ''; // The label of the field. Must match a valid trace key in CDB.
value: string = ''; // The currently selected value in a field.
}
export class TestPickerSk extends ElementSk {
private _fieldData: FieldInfo[] = [];
private _count: string = '';
private _containerDiv: Element | null = null;
private _plotButton: HTMLButtonElement | null = null;
private _requestInProgress: boolean = false;
private _currentIndex: number = 0;
private _defaultParams: { [key: string]: string[] | null } = {};
constructor() {
super(TestPickerSk.template);
}
private static template = (ele: TestPickerSk) => html`
<div id="testPicker">
<div id="fieldContainer"></div>
<div id="queryCount">
<div class="test-picker-sk-matches-container">
Matches: <span>${ele._count}</span
><spinner-sk ?active=${ele._requestInProgress}></spinner-sk>
</div>
<button
id="plot-button"
@click=${ele.onPlotButtonClick}
title="Plot a graph on selected values. Narrow down selection to ${PLOT_MAXIMUM} matches to be able to plot."
disabled>
Add Graph
</button>
</div>
</div>
`;
connectedCallback(): void {
super.connectedCallback();
this._render();
this._containerDiv = this.querySelector('#fieldContainer');
this._plotButton = this.querySelector('#plot-button');
}
/**
* Adds a PickerFieldSk to the fieldContainer div.
*
* Order of events:
* 1. Fetch options for the next child to be added.
* 2. If we receive options, we initialize new PickerFieldSk object. Otherwise, do
* create a new field. Just update the count.
* 2. Populate the field with fetched dropdown options.
* 3. Focus the field.
* 4. Open the dropdown selection menu automatically if it's not the first field.
* 5. Add eventListener to field specifying how to handle selected value changes.
*/
private addChildField() {
const currentIndex = this._currentIndex;
const currentFieldInfo = this._fieldData[currentIndex];
const param = currentFieldInfo.param;
const handler = (json: NextParamListHandlerResponse) => {
this.updateCount(json.count);
if (param in json.paramset && json.paramset[param] !== null) {
const options = json.paramset[param];
options.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }));
const field: PickerFieldSk = new PickerFieldSk(param);
currentFieldInfo.field = field;
this._containerDiv!.appendChild(field);
this._currentIndex += 1;
field!.options = options;
field!.focus();
if (currentIndex !== 0) {
field!.openOverlay();
}
this.addValueChangedEventToField(currentIndex);
}
this._render();
};
this.callNextParamList(handler);
}
/**
* Adds event listener to PickerFieldSk element that handles whenever the
* selected value in a field has "changed".
*
* A value can only be considered "changed", when it's set to either
* empty string or a valid value from the dropdown selection menu.
*
* @param index - index of the FieldInfo to add the event listener to.
*/
private addValueChangedEventToField(index: number) {
const fieldInfo = this._fieldData[index];
fieldInfo.field!.addEventListener('value-changed', (e) => {
const value = (e as CustomEvent).detail.value;
fieldInfo.value = value;
// Remove any child fields, as their values are no longer valid.
// If the value of a parent changes, the child values need to be
// recalculated.
this.removeChildFields(index);
// If the new value is not empty, there's two scenarios:
// 1. If this is not the last param, add a new child field as
// there's still more values to choose from.
// 2. If this is the last param, we are done selecting values.
// Just update the match count to reflect the new selection.
if (value !== '') {
if (index !== this._fieldData.length - 1) {
this.addChildField();
} else {
this.fetchCount();
}
// If new value is empty, simply re-calculate the field options and
// update the count.
} else {
this.fetchOptions(index);
}
});
}
/**
* Removes child fields, given an index.
*
* For example, if the params are:
* ['benchmark', 'bot', 'test', 'subtest_1']
*
* 'benchmark' has 'bot' as a child, which has 'test' as a child,
* and so on.
*
* Given index 0 for 'benchmark' param, this function will remove
* 'bot', 'test', and 'subtest_1' fields if they exist.
*
* @param index
*/
private removeChildFields(index: number) {
while (this._currentIndex - 1 > index) {
const fieldInfo = this._fieldData[this._currentIndex - 1];
fieldInfo.value = '';
this._containerDiv!.removeChild(fieldInfo.field!);
fieldInfo.field = null;
this._currentIndex -= 1;
}
this._render();
}
/**
* Sets the readonly property for all rendered fields.
*
* @param readonly
*/
private setReadOnly(readonly: boolean) {
this._fieldData.forEach((field) => {
if (readonly) {
field.field?.disable();
} else {
field.field?.enable();
}
});
}
/**
* Wrapper for POST Call to backend.
*
* @param handler
*/
private callNextParamList(handler: (json: NextParamListHandlerResponse) => void) {
this.updateCount(-1);
this._requestInProgress = true;
this.setReadOnly(true);
this._render();
const body: NextParamListHandlerRequest = {
q: this.createQueryFromFieldData(),
};
fetch('/_/nextParamList/', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json) => {
this._requestInProgress = false;
this.setReadOnly(false);
handler(json);
})
.catch((msg: any) => {
this._requestInProgress = false;
this.setReadOnly(false);
this._render();
errorMessage(msg);
});
}
/**
* Fetches the values for a given field.
*
* When creating a new field, we need to talk to the backend to
* figure out which options the field can provide as valid options in
* its dropdown menu.
*
* Once options are fetched, the field will be populated. Its dropdown
* menu will be automatically opened, unless it is the first field.
* The match count is also updated.
*
* @param index
*/
private fetchOptions(index: number) {
const fieldInfo = this._fieldData[index];
const field = fieldInfo.field;
const param = field!.label;
const handler = (json: NextParamListHandlerResponse) => {
if (param in json.paramset && json.paramset[param] !== null) {
const options = json.paramset[field!.label];
field!.options = options;
this.updateCount(json.count);
field!.focus();
if (index !== 0) {
field!.openOverlay();
}
this._render();
}
};
this.callNextParamList(handler);
}
/**
* Update the matches count.
*
* Calls '/_/nextParamList/' to calculate how many matches the current
* selection has.
*/
private fetchCount() {
const handler = (json: NextParamListHandlerResponse) => {
this._requestInProgress = false;
this.updateCount(json.count);
this._render();
};
this.callNextParamList(handler);
}
private onPlotButtonClick() {
const detail = {
query: this.createQueryFromFieldData(),
};
this.dispatchEvent(new CustomEvent('plot-button-clicked', { detail: detail, bubbles: true }));
}
/**
* Reset test picker and populate the fields with an input query.
* e.g.:
* populateFieldDataFromQuery(
* 'benchmark=a&bot=b&test=c&subtest1=&subtest2=d',
* ['benchmark', 'bot', 'test', 'subtest1', 'subtest2']
* )
*
* In the above case, fields will be filled in order of hierarchy until an
* empty value is reached. Since subtest1 is empty, it'll stop filling at
* subtest1, leaving subtest1 and subtest2 fields empty.
*
* As another example the query 'bot=b&test=c&subtest1=&subtest2=d', is
* not valid as it's missing the benchmark key.
*
* Note that calling this function will overwrite any current selections
* in the test picker.
*
* @param query
* @param params
*/
populateFieldDataFromQuery(query: string, params: string[]) {
const paramSet = toParamSet(query);
this.initializeFieldData(params);
for (let i = 0; i < this._fieldData.length; i++) {
const fieldInfo = this._fieldData[i];
const param = fieldInfo.param;
if (!(param in paramSet)) {
break;
}
const field: PickerFieldSk = new PickerFieldSk(param);
fieldInfo.field = field;
this._containerDiv!.appendChild(field);
this._currentIndex += 1;
if (paramSet[param][0] === '') {
this.addValueChangedEventToField(i);
this.fetchOptions(i);
break;
}
fieldInfo.value = paramSet[param][0];
field.options = [fieldInfo.value];
field.setValue(fieldInfo.value);
this.addValueChangedEventToField(i);
}
this.fetchCount();
}
/**
* Reads the values currently selected and transforms them to
* query format. Add default values from _defaultParams.
*
* This is necessary to make calls to /_/nextParamList/.
*
* @returns value selection in query format.
*/
createQueryFromFieldData(): string {
const paramSet: ParamSet = {};
this._fieldData.forEach((fieldInfo) => {
if (fieldInfo.value !== '') {
paramSet[fieldInfo.param] = [fieldInfo.value];
}
});
// If all fields are empty, don't add any defaults, which can potentially
// make the query slow. An empty query should be a fast retrieval.
if (Object.keys(paramSet).length === 0) {
return '';
}
// Apply default values.
for (const defaultParamKey in this._defaultParams) {
if (!(defaultParamKey in paramSet)) {
paramSet[defaultParamKey] = this._defaultParams![defaultParamKey]!;
}
}
return fromParamSet(paramSet);
}
/**
* Updates the count and updates the Plot button based on the count.
*
* -1 is a valid value and it sets the count to be empty string.
* This is useful for when we want to display the spinning wheel
* instead, when the count is still being calculated.
*
* Also, enables plotting based on the count. If the count is
* PLOT_MAXIMUM or 0, user is not able to plot.
*
* @param count
*/
private updateCount(count: number) {
if (count === -1) {
this._count = '';
this._plotButton!.disabled = true;
return;
}
this._count = `${count}`;
if (count > PLOT_MAXIMUM || count <= 0) {
this._plotButton!.disabled = true;
} else {
this._plotButton!.disabled = false;
}
}
/**
* Initializes Test Picker from scratch.
*
* Initializes the fieldData structure based on params given, and
* renders the first field for the user.
*
* @param params - A list of params that'll be used to populate
* the field labels and query the DB. The order of the list establishes
* a hierarchy in which each field can be populated.
*
* @param defaultParams - A map of default param values to apply to test
* selections. For example, if defaultParams is { 'bot': ['linux-perf'] },
* queries will automatically get "bot=linux-perf" appended. The exception
* is if bot is already specified in the query, then no defaults are applied.
*/
initializeTestPicker(params: string[], defaultParams: { [key: string]: string[] | null }) {
this.initializeFieldData(params);
this._defaultParams = defaultParams;
this.addChildField();
this._render();
}
/**
* Resets data structures from scratch.
*
* Clears the field container DOM, resets the index,
* resets the fieldData structure, and initializes fieldData
* based on given params.
*
* @param params
*/
private initializeFieldData(params: string[]) {
this._containerDiv!.replaceChildren();
this._currentIndex = 0;
this._fieldData = [];
params.forEach((param) => {
this._fieldData.push({
field: null,
param: param,
value: '',
});
});
}
}
define('test-picker-sk', TestPickerSk);