refactor: Create TileAlertEditor component (#2085)

## 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:
This commit is contained in:
Drew Davis 2026-04-09 13:21:34 -04:00 committed by GitHub
parent d866e99ed7
commit 61db3e8b43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 222 additions and 163 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
refactor: Create TileAlertEditor component

View file

@ -63,7 +63,7 @@ const WebhookChannelForm = <T extends object>(
return (
<div>
<Group gap="md" justify="space-between">
<Group gap="md" justify="space-between" align="flex-start">
<Select
data-testid="select-webhook"
comboboxProps={{

View file

@ -133,38 +133,49 @@ export default function RawSqlChartEditor({
}, [sources, connection]);
return (
<Stack>
<Group align="center" gap={0}>
<Text pe="md" size="sm">
Connection
</Text>
<ConnectionSelectControlled
control={control}
name="connection"
size="xs"
/>
<Group align="center" gap={8} mx="md">
<Text size="sm" ps="md">
Source
<Stack gap="xs">
<Group align="center" gap={0} justify="space-between">
<Group align="center" gap={0}>
<Text pe="md" size="sm">
Connection
</Text>
{isDashboardForm && (
<Tooltip
label="Optional. Required to apply dashboard filters to this chart."
pe="md"
>
<IconHelpCircle size={14} className="cursor-pointer" />
</Tooltip>
)}
<ConnectionSelectControlled
control={control}
name="connection"
size="xs"
/>
<Group align="center" gap={8} mx="md">
<Text size="sm" ps="md">
Source
</Text>
{isDashboardForm && (
<Tooltip
label="Optional. Required to apply dashboard filters to this chart."
pe="md"
>
<IconHelpCircle size={14} className="cursor-pointer" />
</Tooltip>
)}
</Group>
<SourceSelectControlled
control={control}
name="source"
connectionId={connection}
size="xs"
clearable
placeholder="None"
sourceSchemaPreview={sourceSchemaPreview}
/>
</Group>
<Group gap="xs">
<Button
onClick={onOpenDisplaySettings}
size="compact-sm"
variant="secondary"
>
Display Settings
</Button>
</Group>
<SourceSelectControlled
control={control}
name="source"
connectionId={connection}
size="xs"
clearable
placeholder="None"
sourceSchemaPreview={sourceSchemaPreview}
/>
</Group>
<RawSqlChartInstructions displayType={displayType ?? DisplayType.Table} />
<Box style={{ position: 'relative' }}>
@ -179,15 +190,6 @@ export default function RawSqlChartEditor({
/>
<div className={resizeStyles.resizeYHandle} onMouseDown={startResize} />
</Box>
<Group justify="flex-end">
<Button
onClick={onOpenDisplaySettings}
size="compact-sm"
variant="secondary"
>
Display Settings
</Button>
</Group>
</Stack>
);
}

View file

@ -4,9 +4,7 @@ import {
FieldErrors,
UseFormClearErrors,
UseFormSetValue,
useWatch,
} from 'react-hook-form';
import { NativeSelect, NumberInput } from 'react-hook-form-mantine';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
@ -15,20 +13,9 @@ import {
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
Button,
Divider,
Flex,
Group,
Paper,
Stack,
Switch,
Text,
} from '@mantine/core';
import { Box, Button, Divider, Flex, Group, Switch, Text } from '@mantine/core';
import { IconBell, IconCirclePlus } from '@tabler/icons-react';
import { AlertChannelForm } from '@/components/Alerts';
import { AlertScheduleFields } from '@/components/AlertScheduleFields';
import {
ChartEditorFormState,
SavedChartConfigWithSelectArray,
@ -39,16 +26,10 @@ import SourceSchemaPreview from '@/components/SourceSchemaPreview';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { IS_LOCAL_MODE } from '@/config';
import { optionsToSelectData } from '@/utils';
import {
ALERT_CHANNEL_OPTIONS,
DEFAULT_TILE_ALERT,
intervalToMinutes,
TILE_ALERT_INTERVAL_OPTIONS,
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
} from '@/utils/alerts';
import { DEFAULT_TILE_ALERT } from '@/utils/alerts';
import { ChartSeriesEditor } from './ChartSeriesEditor';
import { TileAlertEditor } from './TileAlertEditor';
type ChartEditorControlsProps = {
control: Control<ChartEditorFormState>;
@ -103,18 +84,6 @@ export function ChartEditorControls({
onSubmit,
openDisplaySettings,
}: ChartEditorControlsProps) {
const alertChannelType = useWatch({ control, name: 'alert.channel.type' });
const alertScheduleOffsetMinutes = useWatch({
control,
name: 'alert.scheduleOffsetMinutes',
});
const maxAlertScheduleOffsetMinutes = alert?.interval
? Math.max(intervalToMinutes(alert.interval) - 1, 0)
: 0;
const alertIntervalLabel = alert?.interval
? TILE_ALERT_INTERVAL_OPTIONS[alert.interval]
: undefined;
return (
<>
<Flex mb="md" align="center" justify="space-between">
@ -274,20 +243,19 @@ export function ChartEditorControls({
/>
)}
{(displayType === DisplayType.Line ||
displayType === DisplayType.StackedBar ||
displayType === DisplayType.Number) &&
dashboardId &&
!alert &&
!IS_LOCAL_MODE && (
<Button
variant="subtle"
data-testid="alert-button"
size="sm"
color={alert ? 'red' : 'gray'}
onClick={() =>
setValue('alert', alert ? undefined : DEFAULT_TILE_ALERT)
}
onClick={() => setValue('alert', DEFAULT_TILE_ALERT)}
>
<IconBell size={14} className="me-2" />
{!alert ? 'Add Alert' : 'Remove Alert'}
Add Alert
</Button>
)}
</Group>
@ -334,76 +302,14 @@ export function ChartEditorControls({
</Flex>
)}
{alert && !isRawSqlInput && (
<Paper my="sm">
<Stack gap="xs" data-testid="alert-details">
<Paper px="md" py="sm" radius="xs">
<Text size="xxs" opacity={0.5} mb={4}>
Trigger
</Text>
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Alert when the value
</Text>
<NativeSelect
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
size="xs"
name={`alert.thresholdType`}
control={control}
/>
<NumberInput
size="xs"
w={80}
control={control}
name={`alert.threshold`}
/>
over
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`alert.interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
window via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`alert.channel.type`}
control={control}
/>
</Group>
{alert?.createdBy && (
<Text size="xs" opacity={0.6} mt="xs">
Created by {alert.createdBy.name || alert.createdBy.email}
</Text>
)}
<AlertScheduleFields
control={control}
setValue={setValue}
scheduleOffsetName="alert.scheduleOffsetMinutes"
scheduleStartAtName="alert.scheduleStartAt"
scheduleOffsetMinutes={alertScheduleOffsetMinutes}
maxScheduleOffsetMinutes={maxAlertScheduleOffsetMinutes}
offsetWindowLabel={
alertIntervalLabel
? `from each ${alertIntervalLabel} window`
: 'from each alert window'
}
/>
</Paper>
<Paper px="md" py="sm" radius="xs">
<Text size="xxs" opacity={0.5} mb={4}>
Send to
</Text>
<AlertChannelForm
control={control}
type={alertChannelType}
namePrefix="alert."
/>
</Paper>
</Stack>
</Paper>
<Box mt="sm">
<TileAlertEditor
control={control}
setValue={setValue}
alert={alert}
onRemove={() => setValue('alert', undefined)}
/>
</Box>
)}
</>
);

View file

@ -0,0 +1,146 @@
import { Control, UseFormSetValue, useWatch } from 'react-hook-form';
import { NativeSelect, NumberInput } from 'react-hook-form-mantine';
import {
ActionIcon,
Box,
Collapse,
Group,
Paper,
Text,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconChevronDown, IconTrash } from '@tabler/icons-react';
import { AlertChannelForm } from '@/components/Alerts';
import { AlertScheduleFields } from '@/components/AlertScheduleFields';
import { ChartEditorFormState } from '@/components/ChartEditor/types';
import { optionsToSelectData } from '@/utils';
import {
ALERT_CHANNEL_OPTIONS,
intervalToMinutes,
TILE_ALERT_INTERVAL_OPTIONS,
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
} from '@/utils/alerts';
export function TileAlertEditor({
control,
setValue,
alert,
onRemove,
}: {
control: Control<ChartEditorFormState>;
setValue: UseFormSetValue<ChartEditorFormState>;
alert: NonNullable<ChartEditorFormState['alert']>;
onRemove: () => void;
}) {
const [opened, { toggle }] = useDisclosure(true);
const alertChannelType = useWatch({ control, name: 'alert.channel.type' });
const alertScheduleOffsetMinutes = useWatch({
control,
name: 'alert.scheduleOffsetMinutes',
});
const maxAlertScheduleOffsetMinutes = alert?.interval
? Math.max(intervalToMinutes(alert.interval) - 1, 0)
: 0;
const alertIntervalLabel = alert?.interval
? TILE_ALERT_INTERVAL_OPTIONS[alert.interval]
: undefined;
return (
<Paper data-testid="alert-details">
<Group justify="space-between" px="sm" pt="sm" pb={opened ? 0 : 'sm'}>
<UnstyledButton onClick={toggle}>
<Group gap="xs">
<IconChevronDown
size={14}
style={{
transform: opened ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 200ms',
}}
/>
<Text size="sm" fw={500}>
Alert
</Text>
</Group>
</UnstyledButton>
<Tooltip label="Remove alert">
<ActionIcon
variant="danger"
color="red"
size="sm"
onClick={onRemove}
data-testid="remove-alert-button"
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
<Collapse in={opened}>
<Box px="sm" pb="sm">
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Trigger when the value
</Text>
<NativeSelect
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
size="xs"
name={`alert.thresholdType`}
control={control}
/>
<NumberInput
size="xs"
w={80}
control={control}
name={`alert.threshold`}
/>
over
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`alert.interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
window via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`alert.channel.type`}
control={control}
/>
</Group>
{alert?.createdBy && (
<Text size="xs" opacity={0.6} mt="xs">
Created by {alert.createdBy.name || alert.createdBy.email}
</Text>
)}
<AlertScheduleFields
control={control}
setValue={setValue}
scheduleOffsetName="alert.scheduleOffsetMinutes"
scheduleStartAtName="alert.scheduleStartAt"
scheduleOffsetMinutes={alertScheduleOffsetMinutes}
maxScheduleOffsetMinutes={maxAlertScheduleOffsetMinutes}
offsetWindowLabel={
alertIntervalLabel
? `from each ${alertIntervalLabel} window`
: 'from each alert window'
}
/>
<Text size="xxs" opacity={0.5} mb={4} mt="sm">
Send to
</Text>
<AlertChannelForm
control={control}
type={alertChannelType}
namePrefix="alert."
/>
</Box>
</Collapse>
</Paper>
);
}

View file

@ -439,16 +439,17 @@ describe('DBEditTimeChartForm - Add/delete alerts for display type Number', () =
renderAlertComponent({ onSave });
// Find and click the add alert button
const alertButton = screen.getByTestId('alert-button');
await userEvent.click(alertButton);
const addAlertButton = screen.getByTestId('alert-button');
await userEvent.click(addAlertButton);
// Verify that the alert is added
const alert = screen.getByTestId('alert-details');
expect(alert).toBeInTheDocument();
// The add and remove alert button are the same element
expect(alertButton).toHaveTextContent('Remove Alert');
await userEvent.click(alertButton);
expect(addAlertButton).not.toBeVisible();
const removeAlertButton = screen.getByTestId('remove-alert-button');
await userEvent.click(removeAlertButton);
// Verify that the alert is deleted
expect(alert).not.toBeInTheDocument();

View file

@ -19,7 +19,8 @@ export class ChartEditorComponent {
private readonly sourceSelector: Locator;
private readonly metricSelector: Locator;
private readonly aggFnSelect: Locator;
private readonly addOrRemoveAlertButton: Locator;
private readonly addAlertButton: Locator;
private readonly removeAlertButton: Locator;
private readonly webhookSelector: Locator;
private readonly runQueryButton: Locator;
private readonly saveButton: Locator;
@ -31,7 +32,8 @@ export class ChartEditorComponent {
this.sourceSelector = page.getByTestId('source-selector');
this.metricSelector = page.getByTestId('metric-name-selector');
this.aggFnSelect = page.getByTestId('agg-fn-select');
this.addOrRemoveAlertButton = page.getByTestId('alert-button');
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);
@ -129,7 +131,7 @@ export class ChartEditorComponent {
}
async clickAddAlert() {
await this.addOrRemoveAlertButton.click();
await this.addAlertButton.click();
this.addNewWebhookButton.waitFor({
state: 'visible',
timeout: 2000,
@ -137,8 +139,8 @@ export class ChartEditorComponent {
}
async clickRemoveAlert() {
await this.addOrRemoveAlertButton.click();
this.addNewWebhookButton.waitFor({
await this.removeAlertButton.click();
this.removeAlertButton.waitFor({
state: 'hidden',
timeout: 2000,
});
@ -284,7 +286,7 @@ export class ChartEditorComponent {
}
get alertButton() {
return this.addOrRemoveAlertButton;
return this.addAlertButton;
}
get runButton() {

View file

@ -381,9 +381,6 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
// Hover over first tile to reveal edit button
await dashboardPage.editTile(0);
await expect(dashboardPage.chartEditor.alertButton).toHaveText(
'Remove Alert',
);
await dashboardPage.chartEditor.clickRemoveAlert();
await dashboardPage.saveTile();