mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
## Summary This PR extracts a TileAlertEditor component for future re-use in the Raw-SQL Alert UI. The UI has been updated to make the alert section collapsible and co-locate the "Remove Alert" button within the alert section. The collapsibility will be more important in the Raw SQL case, since the Raw SQL Editor is already pretty vertically tall. ### Screenshots or video https://github.com/user-attachments/assets/4e595fc6-06f0-4ccd-ab1f-08dcb9895c89 ### How to test locally or on Vercel This must be tested locally, since alerts are not supported in local mode. ### References - Linear Issue: Related to HDX-1605 - Related PRs:
299 lines
8.4 KiB
TypeScript
299 lines
8.4 KiB
TypeScript
/**
|
|
* ChartEditorComponent - Reusable component for chart/tile editor
|
|
* Used for creating and configuring dashboard tiles and chart explorer
|
|
*/
|
|
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
|
import { Locator, Page } from '@playwright/test';
|
|
|
|
import { getSqlEditor } from '../utils/locators';
|
|
|
|
import { WebhookAlertModalComponent } from './WebhookAlertModalComponent';
|
|
|
|
export class ChartEditorComponent {
|
|
readonly page: Page;
|
|
readonly addNewWebhookButton: Locator;
|
|
readonly webhookAlertModal: WebhookAlertModalComponent;
|
|
|
|
private readonly chartNameInput: Locator;
|
|
private readonly chartTypeInput: Locator;
|
|
private readonly sourceSelector: Locator;
|
|
private readonly metricSelector: Locator;
|
|
private readonly aggFnSelect: Locator;
|
|
private readonly addAlertButton: Locator;
|
|
private readonly removeAlertButton: Locator;
|
|
private readonly webhookSelector: Locator;
|
|
private readonly runQueryButton: Locator;
|
|
private readonly saveButton: Locator;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
this.chartNameInput = page.getByTestId('chart-name-input');
|
|
this.chartTypeInput = page.getByTestId('chart-type-input');
|
|
this.sourceSelector = page.getByTestId('source-selector');
|
|
this.metricSelector = page.getByTestId('metric-name-selector');
|
|
this.aggFnSelect = page.getByTestId('agg-fn-select');
|
|
this.addAlertButton = page.getByTestId('alert-button');
|
|
this.removeAlertButton = page.getByTestId('remove-alert-button');
|
|
this.webhookSelector = page.getByTestId('select-webhook');
|
|
this.addNewWebhookButton = page.getByTestId('add-new-webhook-button');
|
|
this.webhookAlertModal = new WebhookAlertModalComponent(page);
|
|
this.runQueryButton = page.getByTestId('chart-run-query-button');
|
|
this.saveButton = page.getByTestId('chart-save-button');
|
|
}
|
|
|
|
/**
|
|
* Set chart name
|
|
*/
|
|
async setChartName(name: string) {
|
|
await this.chartNameInput.fill(name);
|
|
}
|
|
|
|
/**
|
|
* Set chart type
|
|
*/
|
|
async setChartType(name: DisplayType) {
|
|
await this.chartTypeInput.getByRole('tab', { name }).click();
|
|
}
|
|
|
|
/**
|
|
* Set group by expression
|
|
*/
|
|
async setGroupBy(expression: string) {
|
|
const groupByInput = getSqlEditor(this.page, 'SQL Columns');
|
|
await groupByInput.click();
|
|
await this.page.keyboard.type(expression);
|
|
}
|
|
|
|
/**
|
|
* Select a data source
|
|
*/
|
|
async selectSource(sourceName: string) {
|
|
await this.sourceSelector.click();
|
|
// Use getByRole for more reliable selection
|
|
const sourceOption = this.page.getByRole('option', { name: sourceName });
|
|
if ((await sourceOption.getAttribute('data-combobox-active')) != 'true') {
|
|
await sourceOption.click({ timeout: 5000 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select a metric by name
|
|
*/
|
|
async selectMetric(metricName: string, metricValue?: string) {
|
|
// Wait for metric selector to be visible
|
|
await this.metricSelector.waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// Click to open dropdown
|
|
await this.metricSelector.click();
|
|
|
|
// Type to filter
|
|
await this.metricSelector.fill(metricName);
|
|
|
|
// If a specific metric value is provided, wait for and click it
|
|
if (metricValue) {
|
|
// Use attribute selector for combobox options
|
|
const targetMetricOption = this.page.locator(
|
|
`[data-combobox-option="true"][value="${metricValue}"]`,
|
|
);
|
|
await targetMetricOption.waitFor({ state: 'visible', timeout: 5000 });
|
|
await targetMetricOption.click({ timeout: 5000 });
|
|
} else {
|
|
// Otherwise just press Enter to select the first match
|
|
await this.page.keyboard.press('Enter');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select an aggregation function from the dropdown
|
|
*/
|
|
async selectAggFn(label: string) {
|
|
await this.aggFnSelect.click();
|
|
await this.page.getByRole('option', { name: label }).click();
|
|
}
|
|
|
|
/**
|
|
* Get the currently selected aggregation function value
|
|
*/
|
|
async getSelectedAggFn(): Promise<string | null> {
|
|
return this.aggFnSelect.inputValue();
|
|
}
|
|
|
|
/**
|
|
* Check if an aggregation function option is available in the dropdown
|
|
*/
|
|
async isAggFnOptionAvailable(label: string): Promise<boolean> {
|
|
await this.aggFnSelect.click();
|
|
const option = this.page.getByRole('option', { name: label });
|
|
const visible = await option.isVisible().catch(() => false);
|
|
// Close the dropdown
|
|
await this.page.keyboard.press('Escape');
|
|
return visible;
|
|
}
|
|
|
|
async clickAddAlert() {
|
|
await this.addAlertButton.click();
|
|
this.addNewWebhookButton.waitFor({
|
|
state: 'visible',
|
|
timeout: 2000,
|
|
});
|
|
}
|
|
|
|
async clickRemoveAlert() {
|
|
await this.removeAlertButton.click();
|
|
this.removeAlertButton.waitFor({
|
|
state: 'hidden',
|
|
timeout: 2000,
|
|
});
|
|
}
|
|
|
|
async selectWebhook(webhookName: string) {
|
|
// Click to open dropdown
|
|
await this.webhookSelector.click();
|
|
|
|
// Type to filter
|
|
await this.webhookSelector.fill(webhookName);
|
|
|
|
// Use getByRole for more reliable selection
|
|
const sourceOption = this.page.getByRole('option', { name: webhookName });
|
|
if ((await sourceOption.getAttribute('data-combobox-active')) != 'true') {
|
|
await sourceOption.click({ timeout: 5000 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the query and wait for it to complete
|
|
*/
|
|
async runQuery(waitForRecharts: boolean = true) {
|
|
await this.runQueryButton.click();
|
|
if (waitForRecharts) {
|
|
// need to wait for the recharts graph to render
|
|
await this.page
|
|
.locator('.recharts-responsive-container')
|
|
.first()
|
|
.waitFor({ state: 'visible', timeout: 10000 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switch the chart editor from Builder to SQL mode.
|
|
*/
|
|
async switchToSqlMode() {
|
|
const sqlLabel = this.page.locator(
|
|
'.mantine-SegmentedControl-label:has-text("SQL")',
|
|
);
|
|
await sqlLabel.waitFor({ state: 'visible', timeout: 5000 });
|
|
await sqlLabel.click();
|
|
}
|
|
|
|
/**
|
|
* Type a SQL query into the CodeMirror SQL editor.
|
|
* Call switchToSqlMode() first to make the SQL editor visible.
|
|
*/
|
|
async typeSqlQuery(sql: string) {
|
|
// Target the cm-content (editable area) inside the SQL template editor.
|
|
// Use first() because the "Generated SQL" accordion may add another
|
|
// cm-editor further down the DOM.
|
|
const sqlContent = this.page.locator('.cm-editor .cm-content').first();
|
|
await sqlContent.click();
|
|
await this.page.keyboard.type(sql);
|
|
}
|
|
|
|
/**
|
|
* Save the chart/tile and wait for modal to close
|
|
*/
|
|
async save() {
|
|
await this.saveButton.click();
|
|
// Wait for save button to disappear (modal closes)
|
|
await this.saveButton.waitFor({ state: 'hidden', timeout: 2000 });
|
|
}
|
|
|
|
/**
|
|
* Wait for chart editor data to load (sources, metrics, etc.)
|
|
*/
|
|
async waitForDataToLoad() {
|
|
await this.runQueryButton.waitFor({ state: 'visible', timeout: 2000 });
|
|
await this.page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
/**
|
|
* Complete workflow: create a basic chart with name and save
|
|
*/
|
|
async createBasicChart(name: string) {
|
|
// Wait for data sources to load before interacting
|
|
await this.waitForDataToLoad();
|
|
await this.setChartName(name);
|
|
await this.runQuery();
|
|
await this.save();
|
|
}
|
|
|
|
/**
|
|
* Complete workflow: create a chart with specific source and metric
|
|
*/
|
|
async createChartWithMetric(
|
|
chartName: string,
|
|
sourceName: string,
|
|
metricName: string,
|
|
metricValue?: string,
|
|
) {
|
|
// Wait for data sources to load before interacting
|
|
await this.waitForDataToLoad();
|
|
await this.selectSource(sourceName);
|
|
await this.selectMetric(metricName, metricValue);
|
|
await this.runQuery();
|
|
await this.save();
|
|
}
|
|
|
|
/**
|
|
* Complete workflow: create a chart with specific source and metric
|
|
*/
|
|
async createTable({
|
|
chartName,
|
|
sourceName,
|
|
groupBy,
|
|
}: {
|
|
chartName: string;
|
|
sourceName: string;
|
|
groupBy?: string;
|
|
}) {
|
|
// Wait for data sources to load before interacting
|
|
await this.waitForDataToLoad();
|
|
|
|
const tableButton = this.page.getByRole('tab', { name: 'Table' });
|
|
await tableButton.click();
|
|
|
|
await this.setChartName(chartName);
|
|
await this.selectSource(sourceName);
|
|
if (groupBy) await this.setGroupBy(groupBy);
|
|
await this.save();
|
|
}
|
|
|
|
// Getters for assertions
|
|
|
|
get nameInput() {
|
|
return this.chartNameInput;
|
|
}
|
|
|
|
get source() {
|
|
return this.sourceSelector;
|
|
}
|
|
|
|
get metric() {
|
|
return this.metricSelector;
|
|
}
|
|
|
|
get aggFn() {
|
|
return this.aggFnSelect;
|
|
}
|
|
|
|
get alertButton() {
|
|
return this.addAlertButton;
|
|
}
|
|
|
|
get runButton() {
|
|
return this.runQueryButton;
|
|
}
|
|
|
|
get saveBtn() {
|
|
return this.saveButton;
|
|
}
|
|
}
|