blob: a3c139539b152d1be419e9e8711fe1341d1aba90 [file] [log] [blame]
/**
* @module modules/new-bug-dialog-sk
* @description <h2><code>new-bug-dialog-sk</code></h2>
*
* Dialog to show when user wants to create a new bug on an untriaged anomaly.
*
* Takes the following inputs:
* - Title
* - Description
* - Labels
* - Components
* - Owner
* - CC's
*
* Once a validated user submits this dialog, there'll be an attempt to create a new
* Buganizer bug. If succesful, user is re-directed to the bug created. If unsuccesful,
* an error message toast will appear.
*
*/
import { html, TemplateResult } from 'lit/html.js';
import { define } from '../../../elements-sk/modules/define';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import { Anomaly } from '../json';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { upgradeProperty } from '../../../elements-sk/modules/upgradeProperty';
import { errorMessage } from '../../../elements-sk/modules/errorMessage';
import { LoggedIn } from '../../../infra-sk/modules/alogin-sk/alogin-sk';
import { Status } from '../../../infra-sk/modules/json';
import '../../../elements-sk/modules/icons/close-icon-sk';
import '../../../elements-sk/modules/spinner-sk';
export class NewBugDialogSk extends ElementSk {
private _dialog: HTMLDialogElement | null = null;
private _bugUrl: string = '';
private _form: HTMLFormElement | null = null;
_anomalies: Anomaly[] = [];
_traceNames: string[] = [];
private _user: string = '';
private _opened: boolean = false;
private INFINITY_PERCENT_CHANGE: string = 'zero-to-nonzero';
private isDragging = false;
private xOffset = 0;
private yOffset = 0;
private static template = (ele: NewBugDialogSk) => html`
<dialog id="new-bug-dialog"
@mousedown=${ele.onmousedown}
@mousemove=${ele.onMouseMove}
@mouseup=${ele.onMouseUp}>
<h2>File New Bug</h2>
<button id="closeIcon" @click=${ele.closeDialog}>
<close-icon-sk></close-icon-sk>
</button>
<form id="new-bug-form">
<label for="title">Title</label>
<input
id="title"
type="text"
required
value=${ele.getBugTitle()}>
</input>
<label for="description">Description</label>
<textarea id="description" rows="10"></textarea>
${ele.hasLabels() ? html`<h3>Labels</h3>` : ''}
${ele.getLabelCheckboxes()}
<h3>Component</h3>
${ele.getComponentRadios()}
<label for="assignee">Assignee</label>
<input
id="assignee"
type="text"
>
</input>
<label for="ccs">CC's (comma-separated e-mails)</label>
<input
id="ccs"
type="text"
value=${ele._user}>
</input>
</form>
</dialog>
<dialog id="loading-popup">
<p>New bug creating in process. Waiting...</p>
</dialog>
`;
constructor() {
super(NewBugDialogSk.template);
}
connectedCallback() {
super.connectedCallback();
upgradeProperty(this, '_anomalies');
upgradeProperty(this, '_user');
this._render();
this._dialog = this.querySelector('#new-bug-dialog');
this._form = this.querySelector('#new-bug-form');
this._form!.addEventListener('submit', (e) => {
e.preventDefault();
this.fileNewBug();
});
document.addEventListener('mousedown', this.onMousedown);
}
/**
* Checks if any of the anomalies have labels.
*/
hasLabels(): boolean {
return this._anomalies.some((anomaly) => anomaly.bug_labels && anomaly.bug_labels!.length > 0);
}
/**
* Gather the labels for all anomalies and present unique checkboxes in the dialog.
*
* These will all be checked by default.
*/
getLabelCheckboxes(): TemplateResult[] {
const checkboxes: TemplateResult[] = [];
// Use a Set to keep track of unique labels and not show duplicates.
const uniqueLabels = new Set<string>();
// Use counter to set id of each checkbox to be unique.
let counter = 0;
this._anomalies.forEach((anomaly) => {
anomaly.bug_labels?.forEach((label) => {
if (!uniqueLabels.has(label)) {
uniqueLabels.add(label);
checkboxes.push(
html`
<div>
<input
type="checkbox"
class="buglabel"
id=${`label-checkbox-${counter}`}
checked
value="${label}">
</input>
<label for=${`label-checkbox-${counter}`}>${label}</label>
</div>
`
);
counter += 1;
}
});
});
return checkboxes;
}
/**
* Gather the components for all anomalies and present unique radios in the dialog.
*
* The first radio is always selected. A radio selection is required.
*/
getComponentRadios(): TemplateResult[] {
const radios: TemplateResult[] = [];
// Use a Set to keep track of unique components
const uniqueComponents = new Set<string>();
// Check if this is the first radio created to mark it as checked.
let isFirst = true;
let counter = 0;
this._anomalies.forEach((anomaly) => {
const component = anomaly.bug_component;
// Only add the radio button if the component is not already in the Set
if (!uniqueComponents.has(component)) {
uniqueComponents.add(component);
const radioId = `component-radio-${counter}`;
radios.push(
html`
<div>
<input
type="radio"
required
id=${radioId}
name="component"
?checked=${isFirst}
value="${component}">
</input>
<label for=${radioId}>${component}</label>
</div>
`
);
isFirst = false;
counter++;
}
});
return radios;
}
/**
* Use anomaly medians to calculate the percent change.
*/
getPercentChangeForAnomaly(anomaly: Anomaly): number {
if (anomaly.median_before_anomaly === 0.0) {
return Number.MAX_VALUE;
}
const difference = anomaly.median_after_anomaly - anomaly.median_before_anomaly;
return (100 * difference) / anomaly.median_before_anomaly;
}
/**
* Gets the percent change of an anomaly and makes it readable.
*
* If percentChange is infinite or undefined, set it to this.INFINITY_PERCENT_CHANGE.
*/
getDisplayPercentChanged(anomaly: Anomaly): string {
if (Math.abs(this.getPercentChangeForAnomaly(anomaly)) === Number.MAX_VALUE) {
return this.INFINITY_PERCENT_CHANGE;
}
return `${Math.abs(this.getPercentChangeForAnomaly(anomaly)).toFixed(1)}%`;
}
/**
* Mimics getSuiteNameForAlert function in Legacy Chromeperf UI.
*
* There are special cases for displaying the names of benchmarks.
*/
getSuiteNameForAlert(anomaly: Anomaly) {
const test_path_parts = anomaly.test_path.split('/');
const testsuite = test_path_parts[2];
const SUITES_WITH_SUBTEST_ENTRY = ['rendering.desktop', 'rendering.mobile', 'v8'];
if (!SUITES_WITH_SUBTEST_ENTRY.includes(testsuite)) {
return testsuite;
}
return `${testsuite}/${test_path_parts[3]}`;
}
/**
* Generates a Bug Title based on anomaly data.
*
* This tries to mimic the getBugTitleForAnomaly function in LegacyChromeperf UI.
*/
getBugTitle() {
if (this._anomalies.length === 0) {
return '';
}
let type = 'improvement';
let percentMin = Infinity;
let percentMax = -Infinity;
let maxRegressionFound = false;
let startRev = Infinity;
let endRev = -Infinity;
for (let i = 0; i < this._anomalies.length; i++) {
const anomaly = this._anomalies[i];
if (!anomaly.is_improvement) {
type = 'regression';
}
let percent = Infinity;
if (
this.getDisplayPercentChanged(anomaly) === this.INFINITY_PERCENT_CHANGE &&
!maxRegressionFound
) {
maxRegressionFound = true;
} else {
percent = Math.abs(parseFloat(this.getDisplayPercentChanged(anomaly)));
}
if (percent < percentMin) {
percentMin = percent;
}
if (percent > percentMax) {
percentMax = percent;
}
if (anomaly.start_revision < startRev) {
startRev = anomaly.start_revision;
}
if (anomaly.end_revision > endRev) {
endRev = anomaly.end_revision;
}
}
// Round the percentages to 1 decimal place.
percentMin = Math.round(percentMin * 10) / 10;
percentMax = Math.round(percentMax * 10) / 10;
let minMax = `${percentMin}%-${percentMax}%`;
if (maxRegressionFound) {
if (percentMin === Infinity) {
// Both percentMin and percentMax were at Infinity.
// Record a huge (TM) regression.
minMax = `A ${this.INFINITY_PERCENT_CHANGE}`;
} else {
// Regressions ranged from Infinity to some other lower percentage.
minMax = `A ${this.INFINITY_PERCENT_CHANGE} to ${percentMin}%`;
}
} else if (percentMin === percentMax) {
minMax = `${percentMin}%`;
}
const suiteTitle = this.getSuiteNameForAlert(this._anomalies[0]);
const summary = '{{range}} {{type}} in {{suite}} at {{start}}:{{end}}'
.replace('{{range}}', minMax)
.replace('{{type}}', type)
.replace('{{suite}}', suiteTitle)
.replace('{{start}}', startRev.toString())
.replace('{{end}}', endRev.toString());
return summary;
}
/**
* Reads the form inputs and attempts to file a new bug.
*
* CCs value is transformed from a comma-separated string to a list.
* Upon success, we redirect the user in a new tab to the new bug.
* Upon failure, we keep the dialog open and show an error toast.
*/
fileNewBug(): void {
const loadingPopup = this.querySelector('#loading-popup') as HTMLDialogElement;
loadingPopup.showModal();
this._render();
// Extract title.
const title = this.querySelector('#title')! as HTMLInputElement;
// Extract description.
const description = this.querySelector('#description')! as HTMLInputElement;
// Extract assignee
const assignee = this.querySelector('#assignee')! as HTMLInputElement;
// Extract CCs
const ccs_value = (this.querySelector('#ccs')! as HTMLInputElement).value;
const ccs = ccs_value.split(',').map((s: string) => s.trim());
// Extract labels.
const label_fields = this.querySelectorAll('input.buglabel');
const labels: string[] = [];
label_fields.forEach((field) => {
if ((field as HTMLInputElement).checked) {
labels.push((field as HTMLInputElement).value);
}
});
// Extract component.
const component_fields = this.querySelectorAll('input[name=component]');
let component = '';
component_fields.forEach((field) => {
if ((field as HTMLInputElement).checked) {
component = (field as HTMLInputElement).value;
}
});
const keys: number[] = this._anomalies.map((a) => Number(a.id));
const body = {
title: title.value,
description: description.value,
assignee: assignee.value,
ccs: ccs,
labels: labels,
component: component,
keys: keys,
trace_names: this._traceNames,
};
fetch('/_/triage/file_bug', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json) => {
loadingPopup.close();
this.closeDialog();
// Open the bug page in new window.
this._bugUrl = `https://issues.chromium.org/issues/${json.bug_id}`;
window.open(this._bugUrl, '_blank');
this._render();
// Update anomalies to reflected new Bug Id.
for (let i = 0; i < this._anomalies.length; i++) {
this._anomalies[i].bug_id = json.bug_id;
}
// Let explore-simple-sk and chart-tooltip-sk that anomalies have changed and we need to re-render.
this.dispatchEvent(
new CustomEvent('anomaly-changed', {
bubbles: true,
composed: true,
detail: {
traceNames: this._traceNames,
anomalies: this._anomalies,
bugId: json.bug_id,
},
})
);
})
.catch(() => {
loadingPopup.close();
this.closeDialog();
errorMessage(
'File new bug request failed due to an internal server error. Please try again.'
);
this._render();
});
}
setAnomalies(anomalies: Anomaly[], traceNames: string[]): void {
this._anomalies = anomalies;
this._traceNames = traceNames;
this._form!.reset();
this._render();
}
open(): void {
this._opened = true;
// If user is logged in, automatically add the e-mail to CC.
LoggedIn().then((loginstatus: Status) => {
this._user = loginstatus.email;
this._render();
});
this._render();
this._dialog!.showModal();
}
closeDialog(): void {
this._opened = false;
this._dialog!.close();
}
get opened() {
return this._opened;
}
private onMouseUp(e: MouseEvent) {
e.preventDefault();
this.isDragging = false;
}
private onMouseMove(e: MouseEvent) {
if (!this.isDragging) {
return;
}
e.preventDefault();
const widthBoundary = window.innerWidth;
const heightBoundary = window.innerHeight;
// Calculate new left offset and right offset
const newLeft = e.clientX - this.xOffset;
const newTop = e.clientY - this.yOffset;
if (
widthBoundary > newLeft &&
widthBoundary > newLeft + Number(this._dialog!.style.width) &&
heightBoundary > newTop &&
heightBoundary > newTop + Number(this._dialog?.style.height)
) {
this._dialog!.style.left = `${newLeft}px`;
this._dialog!.style.top = `${newTop}px`;
}
}
private onMousedown(e: MouseEvent) {
if (e.target !== this._dialog) {
return;
}
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
this.isDragging = true;
// Calculate the offset of the mouse click relative to the dialog's top-left
this.xOffset = e.offsetX;
this.yOffset = e.offsetY;
}
}
define('new-bug-dialog-sk', NewBugDialogSk);