blob: b26e5a69338ae9846952597b63e95372a2dcc217 [file] [log] [blame]
/**
* @module modules/existing-bug-dialog-sk
* @description <h2><code>existing-bug-dialog-sk</code></h2>
*
* Dialog to show when user wants to edit a bug to associate with the alert.
*
* Takes the following inputs:
* Request parameters:
* bug_id: Bug ID number, as a string (when submitting the form).
* project_id: Monorail project ID (when submitting the form).
* keys: Comma-separated alert keys in urlsafe format (when submitting the form).
* confirm: If non-empty, associate alerts with a bug ID even if
* it appears that the alerts already associated with that bug
* have a non-overlapping revision range.
*
* Once a validated user submits this dialog, there'll be an attempt to submit a
* bug id and a project id. If succesful, user is re-directed to the another page and close the dialog. If unsuccesful,
* an error message toast will appear.
*
*/
import '../../../elements-sk/modules/select-sk';
import { html } from 'lit/html.js';
import { define } from '../../../elements-sk/modules/define';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import { Anomaly, Issue } from '../json';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { ProjectId } from '../json';
import { upgradeProperty } from '../../../elements-sk/modules/upgradeProperty';
import { errorMessage } from '../../../elements-sk/modules/errorMessage';
import { SpinnerSk } from '../../../elements-sk/modules/spinner-sk/spinner-sk';
import '../../../elements-sk/modules/icons/close-icon-sk';
import '../../../elements-sk/modules/spinner-sk';
import '../window/window';
export class ExistingBugDialogSk extends ElementSk {
private _dialog: HTMLDialogElement | null = null;
private _spinner: SpinnerSk | null = null;
private _projectId: ProjectId;
private _form: HTMLFormElement | null = null;
private _anomalies: Anomaly[] = [];
private _traceNames: string[] = [];
private allProjectIdOptions: ProjectId[] = [];
private bug_url: string = '';
private _active: boolean = false;
private bug_id: number | undefined;
private _associatedBugIds = new Set<number>();
// maintain a map which maps each bug id associates with its title
private bugIdTitleMap: { [key: number]: string } = {};
// Host bug url, usually from window.perf.bug_host_url.
private _bug_host_url: string = window.perf ? window.perf.bug_host_url : '';
private static allProjectIds = (ele: ExistingBugDialogSk) =>
ele.allProjectIdOptions.map(
(p) => html`
<option
?selected=${ele.innerText === p.toString()}
value=${p.toString()}
title=${p.toString()}>
${p.toString()}
</option>
`
);
private static template = (ele: ExistingBugDialogSk) => html`
<dialog id="existing-bug-dialog">
<h2>Existing Bug</h2>
<div id="add-to-existing-bug">
<button id="closeIcon" @click=${ele.closeDialog} type="close">
<close-icon-sk></close-icon-sk>
</button>
<form id="existing-bug-form">
<select id="existing-bug-dialog-select-project" @input=${ele.projectIdToggle}>
${ExistingBugDialogSk.allProjectIds(ele)}
</select>
<input type="text"
id="bug_id"
placeholder="Bug ID"
pattern="[0-9]{5,9}"
title="Bug ID must be a number, between 5 and 9 digits."
required autocomplete="off"></input>
<br></br>
<div>
<spinner-sk id="loading-spinner"></spinner-sk>
</div>
<div class="bug-list">${ele.associatedBugListTemplate()}<div>
<div class="footer">
<button id="file-button" type="submit">Submit</button>
<button id="close-button" @click=${ele.closeDialog} type="close">Close</button>
</div>
</form>
</div>
</dialog>
`;
constructor() {
super(ExistingBugDialogSk.template);
this._projectId = 'chromium';
this.allProjectIdOptions.push('chromium');
}
connectedCallback() {
super.connectedCallback();
upgradeProperty(this, '_anomalies');
upgradeProperty(this, '_associatedBugIds');
upgradeProperty(this, '_bug_host_url');
upgradeProperty(this, 'bugIdTitleMap');
this._render();
this._spinner = this.querySelector('#loading-spinner');
this._dialog = this.querySelector('#existing-bug-dialog');
this._form = this.querySelector('#existing-bug-form');
this._form!.addEventListener('submit', (e) => {
e.preventDefault();
this.addAnomalyWithExistingBug();
});
this._form!.addEventListener('close', (e) => {
e.preventDefault();
this._associatedBugIds.clear();
});
}
private closeDialog(): void {
this._dialog!.close();
}
private projectIdToggle() {}
/**
* Read the form for choosing an existing bug number for an anomaly alert.
* Upon success, we redirect the user in a new tab to the existing bug.
* Upon failure, we keep the dialog open and show an error toast.
*/
addAnomalyWithExistingBug(): void {
this._spinner!.active = true;
// Disable submit and close button
this.querySelector('#file-button')!.setAttribute('disabled', 'true');
this.querySelector('#close-button')!.setAttribute('disabled', 'true');
this._render();
// Extract bug_id.
const bugId = this.querySelector('#bug_id')! as HTMLInputElement;
this.bug_id = +bugId?.value as number;
const alertKeys: number[] = this._anomalies.map((a) => a.id);
const requestBody = {
bug_id: this.bug_id,
keys: alertKeys,
trace_names: this._traceNames,
};
fetch('/_/triage/associate_alerts', {
method: 'POST',
body: JSON.stringify(requestBody),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then(() => {
this._spinner!.active = false;
this.querySelector('#file-button')!.removeAttribute('disabled');
this.querySelector('#close-button')!.removeAttribute('disabled');
this.closeDialog();
// Open the bug page in new window.
this.bug_url = `https://issues.chromium.org/issues/${this.bug_id as number}`;
window.open(this.bug_url, '_blank');
this._render();
// Update anomalies to reflected the existing Bug Id.
for (let i = 0; i < this._anomalies.length; i++) {
this._anomalies[i].bug_id = this.bug_id as number;
}
// 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: this.bug_id,
},
})
);
})
.catch((msg: any) => {
this._spinner!.active = false;
this.querySelector('#file-button')!.removeAttribute('disabled');
this.querySelector('#close-button')!.removeAttribute('disabled');
errorMessage(msg);
this._render();
});
}
async fetch_associated_bugs() {
this._spinner!.active = true;
await fetch('/_/anomalies/group_report', {
method: 'POST',
body: JSON.stringify({
anomalyIDs: String(this._anomalies.map((a) => a.id)),
}),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json) => {
if (json.sid !== null && !json.anomaly_list) {
// if the sid is not null, it will have to another call with sid to get anomalies
const sid: string = json.sid;
this.fetch_associated_bugs_withSid(sid);
} else {
const anomalies: Anomaly[] = json.anomaly_list || [];
this.getAssociatedBugList(anomalies);
}
if (this._associatedBugIds.size !== 0) {
this.fetch_bug_titles();
}
});
this._spinner!.active = false;
this._render();
}
private async fetch_associated_bugs_withSid(sid: string) {
await fetch('/_/anomalies/group_report', {
method: 'POST',
body: JSON.stringify({
StateId: sid,
}),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json) => {
const anomalies: Anomaly[] = json.anomaly_list || [];
this.getAssociatedBugList(anomalies);
});
}
private async fetch_bug_titles() {
const bugIds = Array.from(this._associatedBugIds);
await fetch('/_/triage/list_issues', {
method: 'POST',
body: JSON.stringify({
IssueIds: bugIds,
}),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json) => {
const issueList: Issue[] = json.issues;
this.bugIdTitleMap = {};
console.info('Issue list length ' + issueList.length);
issueList.forEach((issue) => {
const issueid = issue.issueId ? Number(issue.issueId) : 0;
console.info('Issue id: ' + issueid);
console.log('issue title: ' + issue.issueState?.title);
if (this._associatedBugIds.has(issueid)) {
this.bugIdTitleMap[issueid] = issue.issueState?.title ? issue.issueState!.title : '';
}
});
});
}
private getAssociatedBugList(anomalies: Anomaly[]) {
this._associatedBugIds.clear();
anomalies.forEach((anomaly) => {
if (anomaly.bug_id && anomaly.bug_id !== 0) {
this._associatedBugIds.add(anomaly.bug_id);
}
});
}
private associatedBugListTemplate() {
if (this._anomalies) {
if (this._associatedBugIds.size === 0) {
return html``;
}
return html`
<h4>Associated bugs in the same Anomaly group</h4>
<ul style="max-height: 150px;" id="associated-bugs-table">
${Array.from(this._associatedBugIds).map((bugId) => {
return html` <li>
<a href="${`${this._bug_host_url}/${bugId}`}" target="_blank">${bugId}</a>
<span id="bug-title">${this.bugIdTitleMap[bugId]}</span>
<button
id="paste-bug"
@click=${() => {
this.pasteBugId(bugId);
}}>
Paste
</button>
</li>`;
})}
</ul>
`;
}
return html``;
}
private pasteBugId(bugId: number) {
const inputBug = this.querySelector('#bug_id')! as HTMLInputElement;
inputBug.value = String(bugId);
this._render();
}
setAnomalies(anomalies: Anomaly[], traceNames: string[]): void {
this._anomalies = anomalies;
this._traceNames = traceNames;
this._form!.reset();
this._render();
}
open(): void {
this._render();
this._dialog!.showModal();
this._active = true;
}
get isActive() {
return this._active;
}
}
define('existing-bug-dialog-sk', ExistingBugDialogSk);