mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
d866e99ed7
commit
61db3e8b43
8 changed files with 222 additions and 163 deletions
5
.changeset/two-rocks-punch.md
Normal file
5
.changeset/two-rocks-punch.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
refactor: Create TileAlertEditor component
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue