mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-24 10:56:53 +00:00
Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history
This commit is contained in:
commit
abb3ec3688
@ -124,7 +124,7 @@ export const FUNCTION_NAMES: Record<string, FunctionName> = {
|
||||
RUNNING_DIFF: 'runningDiff',
|
||||
LOG2: 'log2',
|
||||
LOG10: 'log10',
|
||||
CUM_SUM: 'cumSum',
|
||||
CUM_SUM: 'cumulativeSum',
|
||||
EWMA3: 'ewma3',
|
||||
EWMA5: 'ewma5',
|
||||
EWMA7: 'ewma7',
|
||||
|
||||
@ -18,6 +18,11 @@ import UPlot from 'uplot';
|
||||
|
||||
import { dataMatch, optionsUpdateState } from './utils';
|
||||
|
||||
// Extended uPlot interface with custom properties
|
||||
interface ExtendedUPlot extends uPlot {
|
||||
_legendScrollCleanup?: () => void;
|
||||
}
|
||||
|
||||
export interface UplotProps {
|
||||
options: uPlot.Options;
|
||||
data: uPlot.AlignedData;
|
||||
@ -66,6 +71,12 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
|
||||
|
||||
const destroy = useCallback((chart: uPlot | null) => {
|
||||
if (chart) {
|
||||
// Clean up legend scroll event listener
|
||||
const extendedChart = chart as ExtendedUPlot;
|
||||
if (extendedChart._legendScrollCleanup) {
|
||||
extendedChart._legendScrollCleanup();
|
||||
}
|
||||
|
||||
onDeleteRef.current?.(chart);
|
||||
chart.destroy();
|
||||
chartRef.current = null;
|
||||
|
||||
@ -125,7 +125,7 @@ export const queryFunctionsTypesConfig: QueryFunctionConfigType = {
|
||||
log10: {
|
||||
showInput: false,
|
||||
},
|
||||
cumSum: {
|
||||
cumulativeSum: {
|
||||
showInput: false,
|
||||
},
|
||||
ewma3: {
|
||||
|
||||
@ -6,13 +6,16 @@ import { Activity, ChartLine } from 'lucide-react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
|
||||
import Stepper from '../Stepper';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import AlertThreshold from './AlertThreshold';
|
||||
import AnomalyThreshold from './AnomalyThreshold';
|
||||
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
|
||||
|
||||
function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
@ -75,6 +78,11 @@ function AlertCondition(): JSX.Element {
|
||||
</div>
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
|
||||
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
|
||||
{showCondensedLayoutFlag ? (
|
||||
<div className="condensed-advanced-options-container">
|
||||
<AdvancedOptions />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import './styles.scss';
|
||||
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useQuery } from 'react-query';
|
||||
@ -17,6 +18,8 @@ import {
|
||||
THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
THRESHOLD_OPERATOR_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { UpdateThreshold } from './types';
|
||||
import {
|
||||
@ -37,6 +40,7 @@ function AlertThreshold(): JSX.Element {
|
||||
>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
const channels = data?.data || [];
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
@ -81,8 +85,18 @@ function AlertThreshold(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const evaluationWindowContext = showCondensedLayoutFlag ? (
|
||||
<EvaluationSettings />
|
||||
) : (
|
||||
<strong>Evaluation Window.</strong>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="alert-threshold-container">
|
||||
<div
|
||||
className={classNames('alert-threshold-container', {
|
||||
'condensed-alert-threshold-container': showCondensedLayoutFlag,
|
||||
})}
|
||||
>
|
||||
{/* Main condition sentence */}
|
||||
<div className="alert-condition-sentences">
|
||||
<div className="alert-condition-sentence">
|
||||
@ -128,7 +142,7 @@ function AlertThreshold(): JSX.Element {
|
||||
options={THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
during the <strong>Evaluation Window.</strong>
|
||||
during the {evaluationWindowContext}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -84,6 +84,9 @@
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
@ -275,3 +278,43 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-alert-threshold-container,
|
||||
.condensed-anomaly-threshold-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.condensed-advanced-options-container {
|
||||
margin-top: 16px;
|
||||
width: fit-parent;
|
||||
}
|
||||
|
||||
.condensed-evaluation-settings-container {
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 240px;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: var(--bg-slate-400);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import AlertCondition from './AlertCondition';
|
||||
import { CreateAlertProvider } from './context';
|
||||
import CreateAlertHeader from './CreateAlertHeader';
|
||||
import EvaluationSettings from './EvaluationSettings';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import QuerySection from './QuerySection';
|
||||
import { showCondensedLayout } from './utils';
|
||||
|
||||
function CreateAlertV2({
|
||||
initialQuery = initialQueriesMap.metrics,
|
||||
@ -16,14 +19,18 @@ function CreateAlertV2({
|
||||
}): JSX.Element {
|
||||
useShareBuilderUrl({ defaultValue: initialQuery });
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
return (
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider>
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertHeader />
|
||||
<QuerySection />
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>
|
||||
</div>
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</CreateAlertProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
import { Collapse, Input, Select, Typography } from 'antd';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptionItem from './AdvancedOptionItem';
|
||||
import EvaluationCadence from './EvaluationCadence';
|
||||
|
||||
function AdvancedOptions(): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const timeOptions = Y_AXIS_CATEGORIES.find(
|
||||
(category) => category.name === 'Time',
|
||||
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
|
||||
|
||||
return (
|
||||
<div className="advanced-options-container">
|
||||
<Collapse bordered={false}>
|
||||
<Collapse.Panel header="ADVANCED OPTIONS" key="1">
|
||||
<EvaluationCadence />
|
||||
<AdvancedOptionItem
|
||||
title="Alert when data stops coming"
|
||||
description="Send notification if no data is received for a specified time period."
|
||||
tooltipText="Useful for monitoring data pipelines or services that should continuously send data. For example, alert if no logs are received for 10 minutes"
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter tolerance limit..."
|
||||
type="number"
|
||||
style={{ width: 100 }}
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit: Number(e.target.value),
|
||||
timeUnit: advancedOptions.sendNotificationIfDataIsMissing.timeUnit,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={timeOptions}
|
||||
placeholder="Select time unit"
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit:
|
||||
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
|
||||
timeUnit: value as string,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Minimum data required"
|
||||
description="Only trigger alert when there are enough data points to make a reliable decision."
|
||||
tooltipText="Prevents false alarms when there's insufficient data. For example, require at least 5 data points before checking if CPU usage is above 80%."
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter minimum datapoints..."
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Account for data delay"
|
||||
description="Shift the evaluation window backwards to account for data processing delays."
|
||||
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
|
||||
input={
|
||||
<div className="advanced-option-item-input-group">
|
||||
<Input
|
||||
placeholder="Enter delay..."
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
onChange={(e): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: Number(e.target.value),
|
||||
timeUnit: advancedOptions.delayEvaluation.timeUnit,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.delayEvaluation.delay}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={timeOptions}
|
||||
placeholder="Select time unit"
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: advancedOptions.delayEvaluation.delay,
|
||||
timeUnit: value as string,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.delayEvaluation.timeUnit}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdvancedOptions;
|
||||
@ -0,0 +1,91 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Button, Popover, Typography } from 'antd';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import Stepper from '../Stepper';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import AdvancedOptions from './AdvancedOptions';
|
||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
||||
|
||||
function EvaluationSettings(): JSX.Element {
|
||||
const {
|
||||
alertType,
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
} = useCreateAlertState();
|
||||
const [
|
||||
isEvaluationWindowPopoverOpen,
|
||||
setIsEvaluationWindowPopoverOpen,
|
||||
] = useState(false);
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const popoverContent = (
|
||||
<Popover
|
||||
open={isEvaluationWindowPopoverOpen}
|
||||
onOpenChange={(visibility: boolean): void => {
|
||||
setIsEvaluationWindowPopoverOpen(visibility);
|
||||
}}
|
||||
content={
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
showArrow={false}
|
||||
>
|
||||
<Button>
|
||||
<div className="evaluate-alert-conditions-button-left">
|
||||
{getTimeframeText(evaluationWindow)}
|
||||
</div>
|
||||
<div className="evaluate-alert-conditions-button-right">
|
||||
<div className="evaluate-alert-conditions-button-right-text">
|
||||
{getEvaluationWindowTypeText(evaluationWindow.windowType)}
|
||||
</div>
|
||||
{isEvaluationWindowPopoverOpen ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
// Layout consists of only the evaluation window popover
|
||||
if (showCondensedLayoutFlag) {
|
||||
return (
|
||||
<div
|
||||
className="condensed-evaluation-settings-container"
|
||||
data-testid="condensed-evaluation-settings-container"
|
||||
>
|
||||
{popoverContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Layout consists of
|
||||
// - Stepper header
|
||||
// - Evaluation window popover
|
||||
// - Advanced options
|
||||
return (
|
||||
<div className="evaluation-settings-container">
|
||||
<Stepper stepNumber={3} label="Evaluation settings" />
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
|
||||
<div className="evaluate-alert-conditions-container">
|
||||
<Typography.Text>Check conditions using data from</Typography.Text>
|
||||
<div className="evaluate-alert-conditions-separator" />
|
||||
{popoverContent}
|
||||
</div>
|
||||
)}
|
||||
<AdvancedOptions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationSettings;
|
||||
@ -0,0 +1,221 @@
|
||||
import { Input, Select, Typography } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
|
||||
import {
|
||||
CUMULATIVE_WINDOW_DESCRIPTION,
|
||||
ROLLING_WINDOW_DESCRIPTION,
|
||||
TIMEZONE_DATA,
|
||||
} from '../constants';
|
||||
import TimeInput from '../TimeInput';
|
||||
import { IEvaluationWindowDetailsProps } from '../types';
|
||||
import { getCumulativeWindowTimeframeText } from '../utils';
|
||||
|
||||
function EvaluationWindowDetails({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowDetailsProps): JSX.Element {
|
||||
const currentHourOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const currentMonthOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 1; i <= 31; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe === 'custom'
|
||||
) {
|
||||
return `Last ${evaluationWindow.startingAt.number} ${
|
||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
|
||||
(option) => option.value === evaluationWindow.startingAt.unit,
|
||||
)?.label
|
||||
}`;
|
||||
}
|
||||
if (evaluationWindow.windowType === 'cumulative') {
|
||||
return getCumulativeWindowTimeframeText(evaluationWindow);
|
||||
}
|
||||
return '';
|
||||
}, [evaluationWindow]);
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'rolling' &&
|
||||
evaluationWindow.timeframe !== 'custom'
|
||||
) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
const isCurrentHour =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentHour';
|
||||
const isCurrentDay =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentDay';
|
||||
const isCurrentMonth =
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
evaluationWindow.timeframe === 'currentMonth';
|
||||
|
||||
const handleNumberChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: value,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: value,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnitChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
unit: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: value,
|
||||
unit: evaluationWindow.startingAt.unit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING AT MINUTE</Typography.Text>
|
||||
<Select
|
||||
options={currentHourOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentDay) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentMonth) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON DAY</Typography.Text>
|
||||
<Select
|
||||
options={currentMonthOptions}
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
<TimeInput
|
||||
value={evaluationWindow.startingAt.time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group">
|
||||
<Typography.Text>SELECT TIMEZONE</Typography.Text>
|
||||
<Select
|
||||
options={TIMEZONE_DATA}
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>Specify custom duration</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>VALUE</Typography.Text>
|
||||
<Input
|
||||
name="value"
|
||||
type="number"
|
||||
value={evaluationWindow.startingAt.number}
|
||||
onChange={(e): void => handleNumberChange(e.target.value)}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>UNIT</Typography.Text>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
value={evaluationWindow.startingAt.unit || null}
|
||||
onChange={handleUnitChange}
|
||||
placeholder="Select unit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowDetails;
|
||||
@ -0,0 +1,161 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
CUMULATIVE_WINDOW_DESCRIPTION,
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
ROLLING_WINDOW_DESCRIPTION,
|
||||
} from '../constants';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
IEvaluationWindowPopoverProps,
|
||||
RollingWindowTimeframes,
|
||||
} from '../types';
|
||||
import EvaluationWindowDetails from './EvaluationWindowDetails';
|
||||
import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNavigation';
|
||||
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const {
|
||||
containerRef,
|
||||
firstItemRef,
|
||||
} = useKeyboardNavigationForEvaluationWindowPopover({
|
||||
onSelect: (value: string, sectionId: string): void => {
|
||||
if (sectionId === 'window-type') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
});
|
||||
} else if (sectionId === 'timeframe') {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
});
|
||||
}
|
||||
},
|
||||
onEscape: (): void => {
|
||||
const triggerElement = document.querySelector(
|
||||
'[aria-haspopup="true"]',
|
||||
) as HTMLElement;
|
||||
triggerElement?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const renderEvaluationWindowContent = (
|
||||
label: string,
|
||||
contentOptions: Array<{ label: string; value: string }>,
|
||||
currentValue: string,
|
||||
onChange: (value: string) => void,
|
||||
sectionId: string,
|
||||
): JSX.Element => (
|
||||
<div className="evaluation-window-content-item" data-section-id={sectionId}>
|
||||
<Typography.Text className="evaluation-window-content-item-label">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="evaluation-window-content-list">
|
||||
{contentOptions.map((option, index) => (
|
||||
<div
|
||||
className={classNames('evaluation-window-content-list-item', {
|
||||
active: currentValue === option.value,
|
||||
})}
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(option.value);
|
||||
}
|
||||
}}
|
||||
ref={index === 0 ? firstItemRef : undefined}
|
||||
>
|
||||
<Typography.Text>{option.label}</Typography.Text>
|
||||
{currentValue === option.value && <Check size={12} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectionContent = (): JSX.Element => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
if (evaluationWindow.timeframe === 'custom') {
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
!evaluationWindow.timeframe
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="evaluation-window-popover"
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
aria-label="Evaluation window options"
|
||||
>
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: value as 'rolling' | 'cumulative',
|
||||
}),
|
||||
'window-type',
|
||||
)}
|
||||
{renderEvaluationWindowContent(
|
||||
'TIMEFRAME',
|
||||
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
|
||||
evaluationWindow.timeframe,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
}),
|
||||
'timeframe',
|
||||
)}
|
||||
{renderSelectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@ -0,0 +1,3 @@
|
||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@ -0,0 +1,180 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseKeyboardNavigationOptions {
|
||||
onSelect?: (value: string, sectionId: string) => void;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavigationForEvaluationWindowPopover = ({
|
||||
onSelect,
|
||||
onEscape,
|
||||
}: UseKeyboardNavigationOptions = {}): {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
firstItemRef: React.RefObject<HTMLDivElement>;
|
||||
} => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getFocusableItems = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
return Array.from(
|
||||
containerRef.current.querySelectorAll(
|
||||
'.evaluation-window-content-list-item[tabindex="0"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getInteractiveElements = useCallback((): HTMLElement[] => {
|
||||
if (!containerRef.current) return [];
|
||||
|
||||
const detailsSection = containerRef.current.querySelector(
|
||||
'.evaluation-window-details',
|
||||
);
|
||||
if (!detailsSection) return [];
|
||||
|
||||
return Array.from(
|
||||
detailsSection.querySelectorAll(
|
||||
'input, select, button, [tabindex="0"], [tabindex="-1"]',
|
||||
),
|
||||
) as HTMLElement[];
|
||||
}, []);
|
||||
|
||||
const getCurrentIndex = useCallback((items: HTMLElement[]): number => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
return items.findIndex((item) => item === activeElement);
|
||||
}, []);
|
||||
|
||||
const navigateWithinSection = useCallback(
|
||||
(direction: 'up' | 'down'): void => {
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
let nextIndex: number;
|
||||
if (direction === 'down') {
|
||||
nextIndex = (currentIndex + 1) % items.length;
|
||||
} else {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
}
|
||||
|
||||
items[nextIndex]?.focus();
|
||||
},
|
||||
[getFocusableItems, getCurrentIndex],
|
||||
);
|
||||
|
||||
const navigateToDetails = useCallback((): void => {
|
||||
const interactiveElements = getInteractiveElements();
|
||||
interactiveElements[0]?.focus();
|
||||
}, [getInteractiveElements]);
|
||||
|
||||
const navigateBackToSection = useCallback((): void => {
|
||||
const items = getFocusableItems();
|
||||
items[0]?.focus();
|
||||
}, [getFocusableItems]);
|
||||
|
||||
const navigateBetweenSections = useCallback(
|
||||
(direction: 'left' | 'right'): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isInDetails = activeElement?.closest('.evaluation-window-details');
|
||||
|
||||
if (isInDetails && direction === 'left') {
|
||||
navigateBackToSection();
|
||||
return;
|
||||
}
|
||||
|
||||
const items = getFocusableItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = getCurrentIndex(items);
|
||||
const DATA_ATTR = 'data-section-id';
|
||||
const currentSectionId = items[currentIndex]?.getAttribute(DATA_ATTR);
|
||||
|
||||
if (currentSectionId === 'window-type' && direction === 'right') {
|
||||
const timeframeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'timeframe',
|
||||
);
|
||||
timeframeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'left') {
|
||||
const windowTypeItem = items.find(
|
||||
(item) => item.getAttribute(DATA_ATTR) === 'window-type',
|
||||
);
|
||||
windowTypeItem?.focus();
|
||||
} else if (currentSectionId === 'timeframe' && direction === 'right') {
|
||||
navigateToDetails();
|
||||
}
|
||||
},
|
||||
[
|
||||
navigateBackToSection,
|
||||
navigateToDetails,
|
||||
getFocusableItems,
|
||||
getCurrentIndex,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSelection = useCallback((): void => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (!activeElement || !onSelect) return;
|
||||
|
||||
const value = activeElement.getAttribute('data-value');
|
||||
const sectionId = activeElement.getAttribute('data-section-id');
|
||||
|
||||
if (value && sectionId) {
|
||||
onSelect(value, sectionId);
|
||||
}
|
||||
}, [onSelect]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('down');
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
navigateWithinSection('up');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
navigateBetweenSections('right');
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
handleSelection();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onEscape?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigateWithinSection, navigateBetweenSections, handleSelection, onEscape],
|
||||
);
|
||||
|
||||
useEffect((): (() => void) | undefined => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => container.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (firstItemRef.current) {
|
||||
firstItemRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef: containerRef as React.RefObject<HTMLDivElement>,
|
||||
firstItemRef: firstItemRef as React.RefObject<HTMLDivElement>,
|
||||
};
|
||||
};
|
||||
@ -49,3 +49,40 @@
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.time-input-container {
|
||||
.time-input-field {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,141 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
|
||||
import AdvancedOptions from '../AdvancedOptions';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
const mockSetAdvancedOptions = jest.fn();
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setAdvancedOptions: mockSetAdvancedOptions,
|
||||
}),
|
||||
);
|
||||
|
||||
const ALERT_WHEN_DATA_STOPS_COMING_TEXT = 'Alert when data stops coming';
|
||||
const MINIMUM_DATA_REQUIRED_TEXT = 'Minimum data required';
|
||||
const ACCOUNT_FOR_DATA_DELAY_TEXT = 'Account for data delay';
|
||||
const ADVANCED_OPTION_ITEM_CLASS = '.advanced-option-item';
|
||||
const SWITCH_ROLE_SELECTOR = '[role="switch"]';
|
||||
|
||||
describe('AdvancedOptions', () => {
|
||||
it('should render evaluation cadence and the advanced options minimized by default', () => {
|
||||
render(<AdvancedOptions />);
|
||||
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
|
||||
expect(screen.queryByText('How often to check')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to expand the advanced options', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
expect(
|
||||
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
expect(screen.getByText('How often to check')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
|
||||
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"Alert when data stops coming" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const alertWhenDataStopsComingContainer = screen
|
||||
.getByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const alertWhenDataStopsComingSwitch = alertWhenDataStopsComingContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(alertWhenDataStopsComingSwitch);
|
||||
|
||||
const toleranceInput = screen.getByPlaceholderText(
|
||||
'Enter tolerance limit...',
|
||||
);
|
||||
fireEvent.change(toleranceInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit: 10,
|
||||
timeUnit: 'min',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('"Minimum data required" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const minimumDataRequiredContainer = screen
|
||||
.getByText(MINIMUM_DATA_REQUIRED_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const minimumDataRequiredSwitch = minimumDataRequiredContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(minimumDataRequiredSwitch);
|
||||
|
||||
const minimumDataRequiredInput = screen.getByPlaceholderText(
|
||||
'Enter minimum datapoints...',
|
||||
);
|
||||
fireEvent.change(minimumDataRequiredInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: {
|
||||
minimumDatapoints: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('"Account for data delay" works as expected', () => {
|
||||
render(<AdvancedOptions />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
|
||||
const accountForDataDelayContainer = screen
|
||||
.getByText(ACCOUNT_FOR_DATA_DELAY_TEXT)
|
||||
.closest(ADVANCED_OPTION_ITEM_CLASS);
|
||||
const accountForDataDelaySwitch = accountForDataDelayContainer?.querySelector(
|
||||
SWITCH_ROLE_SELECTOR,
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(accountForDataDelaySwitch);
|
||||
|
||||
const delayInput = screen.getByPlaceholderText('Enter delay...');
|
||||
fireEvent.change(delayInput, { target: { value: '10' } });
|
||||
|
||||
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: {
|
||||
delay: 10,
|
||||
timeUnit: 'min',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as alertState from 'container/CreateAlertV2/context';
|
||||
import * as utils from 'container/CreateAlertV2/utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import EvaluationSettings from '../EvaluationSettings';
|
||||
import { createMockAlertContextState } from './testUtils';
|
||||
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setEvaluationWindow: mockSetEvaluationWindow,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('../AdvancedOptions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="advanced-options">AdvancedOptions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const EVALUATION_SETTINGS_TEXT = 'Evaluation settings';
|
||||
const CHECK_CONDITIONS_USING_DATA_FROM_TEXT =
|
||||
'Check conditions using data from';
|
||||
|
||||
describe('EvaluationSettings', () => {
|
||||
it('should render the default evaluation settings layout', () => {
|
||||
render(<EvaluationSettings />);
|
||||
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('advanced-options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render evaluation window for anomaly based alert', () => {
|
||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
|
||||
createMockAlertContextState({
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
}),
|
||||
);
|
||||
render(<EvaluationSettings />);
|
||||
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the condensed evaluation settings layout', () => {
|
||||
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
|
||||
render(<EvaluationSettings />);
|
||||
// Header, check conditions using data from and advanced options should be hidden
|
||||
expect(screen.queryByText(EVALUATION_SETTINGS_TEXT)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('advanced-options')).not.toBeInTheDocument();
|
||||
// Only evaluation window popover should be visible
|
||||
expect(
|
||||
screen.getByTestId('condensed-evaluation-settings-container'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,200 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
|
||||
import EvaluationWindowDetails from '../EvaluationWindowPopover/EvaluationWindowDetails';
|
||||
import { createMockEvaluationWindowState } from './testUtils';
|
||||
|
||||
const mockEvaluationWindowState = createMockEvaluationWindowState();
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
|
||||
describe('EvaluationWindowDetails', () => {
|
||||
it('should render the evaluation window details for rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '5',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current hour', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current hour, starting at minute 1 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current day', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current day, starting from 00:00:00 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the evaluation window details for cumulative mode with current month', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Current month, starting from day 1 at 00:00:00 (UTC)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to change the value in rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'rolling',
|
||||
timeframe: 'custom',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '5',
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueInput = screen.getByPlaceholderText('Enter value');
|
||||
fireEvent.change(valueInput, { target: { value: '10' } });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: { ...mockEvaluationWindowState.startingAt, number: '10' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current hour', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: '1',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectComponent = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectComponent);
|
||||
const option = screen.getByText('10');
|
||||
fireEvent.click(option);
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
number: 10,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current day', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '00:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeInputs = screen.getAllByDisplayValue('00');
|
||||
const hoursInput = timeInputs[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '10' } });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
...mockEvaluationWindowState.startingAt,
|
||||
time: '10:00:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to change the value in cumulative mode with current month', () => {
|
||||
render(
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentMonth',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
const daySelectComponent = comboboxes[0];
|
||||
fireEvent.mouseDown(daySelectComponent);
|
||||
const option = screen.getByText('10');
|
||||
fireEvent.click(option);
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: { ...mockEvaluationWindowState.startingAt, number: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,298 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
|
||||
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from '../constants';
|
||||
import EvaluationWindowPopover from '../EvaluationWindowPopover';
|
||||
import { createMockEvaluationWindowState } from './testUtils';
|
||||
|
||||
const mockEvaluationWindow: EvaluationWindowState = createMockEvaluationWindowState();
|
||||
const mockSetEvaluationWindow = jest.fn();
|
||||
|
||||
const EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS =
|
||||
'.evaluation-window-content-list-item';
|
||||
const EVALUATION_WINDOW_DETAILS_TEST_ID = 'evaluation-window-details';
|
||||
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
|
||||
const EVALUATION_WINDOW_TEXT = 'EVALUATION WINDOW';
|
||||
const LAST_5_MINUTES_TEXT = 'Last 5 minutes';
|
||||
|
||||
jest.mock('../EvaluationWindowPopover/EvaluationWindowDetails', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid={EVALUATION_WINDOW_DETAILS_TEST_ID}>
|
||||
<input placeholder={ENTER_VALUE_PLACEHOLDER} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('EvaluationWindowPopover', () => {
|
||||
it('should render the evaluation window popover with 3 sections', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all window type options with rolling selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TYPE.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(rollingItem).toHaveClass('active');
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(cumulativeItem).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all window type options with cumulative selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TYPE.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(cumulativeItem).toHaveClass('active');
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(rollingItem).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all timeframe options in rolling mode with last 5 minutes selected by default', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TIMEFRAME.rolling.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const last5MinutesItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(last5MinutesItem).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should render all timeframe options in cumulative mode with current hour selected by default', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
EVALUATION_WINDOW_TIMEFRAME.cumulative.forEach((option) => {
|
||||
expect(screen.getByText(option.label)).toBeInTheDocument();
|
||||
});
|
||||
const currentHourItem = screen
|
||||
.getByText('Current hour')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
expect(currentHourItem).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('renders help text in details section for rolling mode with non-custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EvaluationWindowDetails component in details section for rolling mode with custom timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
timeframe: 'custom',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EvaluationWindowDetails component in details section for cumulative mode', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={createMockEvaluationWindowState({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
})}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'A Cumulative Window has a fixed starting point and expands over time.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should navigate down through window type options', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
rollingItem?.focus();
|
||||
|
||||
fireEvent.keyDown(rollingItem, { key: 'ArrowDown' });
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(cumulativeItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate up through window type options', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: 'ArrowUp' });
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(rollingItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate right from window type to timeframe', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
rollingItem?.focus();
|
||||
|
||||
fireEvent.keyDown(rollingItem, { key: 'ArrowRight' });
|
||||
const timeframeItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(timeframeItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should navigate left from timeframe to window type', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timeframeItem = screen
|
||||
.getByText(LAST_5_MINUTES_TEXT)
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
timeframeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(timeframeItem, { key: 'ArrowLeft' });
|
||||
const rollingItem = screen
|
||||
.getByText('Rolling')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
|
||||
expect(rollingItem).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should select option with Enter key', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: 'Enter' });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'cumulative',
|
||||
});
|
||||
});
|
||||
|
||||
it('should select option with Space key', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cumulativeItem = screen
|
||||
.getByText('Cumulative')
|
||||
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
|
||||
cumulativeItem?.focus();
|
||||
|
||||
fireEvent.keyDown(cumulativeItem, { key: ' ' });
|
||||
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'cumulative',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -3,8 +3,12 @@ import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import { ICreateAlertContextProps } from 'container/CreateAlertV2/context/types';
|
||||
import {
|
||||
EvaluationWindowState,
|
||||
ICreateAlertContextProps,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
export const createMockAlertContextState = (
|
||||
@ -20,5 +24,14 @@ export const createMockAlertContextState = (
|
||||
setAdvancedOptions: jest.fn(),
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
setEvaluationWindow: jest.fn(),
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
setNotificationSettings: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockEvaluationWindowState = (
|
||||
overrides?: Partial<EvaluationWindowState>,
|
||||
): EvaluationWindowState => ({
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ export const EVALUATION_WINDOW_TIMEFRAME = {
|
||||
{ label: 'Last 1 hour', value: '1h0m0s' },
|
||||
{ label: 'Last 2 hours', value: '2h0m0s' },
|
||||
{ label: 'Last 4 hours', value: '4h0m0s' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
],
|
||||
cumulative: [
|
||||
{ label: 'Current hour', value: 'currentHour' },
|
||||
@ -60,3 +61,9 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
label: `${timezone.name} (${timezone.offset})`,
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
export const CUMULATIVE_WINDOW_DESCRIPTION =
|
||||
'A Cumulative Window has a fixed starting point and expands over time.';
|
||||
|
||||
export const ROLLING_WINDOW_DESCRIPTION =
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.';
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import EvaluationSettings from './EvaluationSettings';
|
||||
|
||||
export default EvaluationSettings;
|
||||
@ -238,7 +238,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
.select-group .ant-input:not(.time-input-field) {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
@ -32,8 +32,6 @@ export enum CumulativeWindowTimeframes {
|
||||
export interface IEvaluationWindowPopoverProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowDetailsProps {
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { Select, Tooltip, Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
function MultipleNotifications(): JSX.Element {
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const spaceAggregationOptions = useMemo(() => {
|
||||
const allGroupBys = currentQuery.builder.queryData?.reduce<string[]>(
|
||||
(acc, query) => {
|
||||
const groupByKeys = query.groupBy?.map((groupBy) => groupBy.key) || [];
|
||||
return [...acc, ...groupByKeys];
|
||||
},
|
||||
[],
|
||||
);
|
||||
const uniqueGroupBys = [...new Set(allGroupBys)];
|
||||
return uniqueGroupBys.map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}));
|
||||
}, [currentQuery.builder.queryData]);
|
||||
|
||||
const isMultipleNotificationsEnabled = spaceAggregationOptions.length > 0;
|
||||
|
||||
const multipleNotificationsInput = useMemo(() => {
|
||||
const placeholder = isMultipleNotificationsEnabled
|
||||
? 'Select fields to group by (optional)'
|
||||
: 'No grouping fields available';
|
||||
let input = (
|
||||
<div>
|
||||
<Select
|
||||
options={spaceAggregationOptions}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
value={notificationSettings.multipleNotifications}
|
||||
mode="multiple"
|
||||
placeholder={placeholder}
|
||||
disabled={!isMultipleNotificationsEnabled}
|
||||
aria-disabled={!isMultipleNotificationsEnabled}
|
||||
maxTagCount={3}
|
||||
/>
|
||||
{isMultipleNotificationsEnabled && (
|
||||
<Typography.Paragraph className="multiple-notifications-select-description">
|
||||
{notificationSettings.multipleNotifications?.length
|
||||
? `Alerts with same ${notificationSettings.multipleNotifications?.join(
|
||||
', ',
|
||||
)} will be grouped`
|
||||
: 'Empty = all matching alerts combined into one notification'}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (!isMultipleNotificationsEnabled) {
|
||||
input = (
|
||||
<Tooltip title="Add 'Group by' fields to your query to enable alert grouping">
|
||||
{input}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}, [
|
||||
isMultipleNotificationsEnabled,
|
||||
notificationSettings.multipleNotifications,
|
||||
setNotificationSettings,
|
||||
spaceAggregationOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="multiple-notifications-container">
|
||||
<div className="multiple-notifications-header">
|
||||
<Typography.Text className="multiple-notifications-header-title">
|
||||
Group alerts by{' '}
|
||||
<Tooltip title="Group similar alerts together to reduce notification volume. Leave empty to combine all matching alerts into one notification without grouping.">
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="multiple-notifications-header-description">
|
||||
Combine alerts with the same field values into a single notification.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{multipleNotificationsInput}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultipleNotifications;
|
||||
@ -0,0 +1,92 @@
|
||||
import { Button, Popover, Tooltip, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
function NotificationMessage(): JSX.Element {
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const templateVariables = [
|
||||
{ variable: '{{alertname}}', description: 'Name of the alert rule' },
|
||||
{
|
||||
variable: '{{value}}',
|
||||
description: 'Current value that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{threshold}}',
|
||||
description: 'Threshold value from alert condition',
|
||||
},
|
||||
{ variable: '{{unit}}', description: 'Unit of measurement for the metric' },
|
||||
{
|
||||
variable: '{{severity}}',
|
||||
description: 'Alert severity level (Critical, Warning, Info)',
|
||||
},
|
||||
{
|
||||
variable: '{{queryname}}',
|
||||
description: 'Name of the query that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{labels}}',
|
||||
description: 'All labels associated with the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{timestamp}}',
|
||||
description: 'Timestamp when alert was triggered',
|
||||
},
|
||||
];
|
||||
|
||||
const templateVariableContent = (
|
||||
<div className="template-variable-content">
|
||||
<Typography.Text strong>Available Template Variables:</Typography.Text>
|
||||
{templateVariables.map((item) => (
|
||||
<div className="template-variable-content-item" key={item.variable}>
|
||||
<code>{item.variable}</code>
|
||||
<Typography.Text>{item.description}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="notification-message-container">
|
||||
<div className="notification-message-header">
|
||||
<div className="notification-message-header-content">
|
||||
<Typography.Text className="notification-message-header-title">
|
||||
Notification Message
|
||||
<Tooltip title="Customize the message content sent in alert notifications. Template variables like {{alertname}}, {{value}}, and {{threshold}} will be replaced with actual values when the alert fires.">
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="notification-message-header-description">
|
||||
Custom message content for alert notifications. Use template variables to
|
||||
include dynamic information.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="notification-message-header-actions">
|
||||
<Popover content={templateVariableContent}>
|
||||
<Button type="text">
|
||||
<Info size={12} />
|
||||
Variables
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
value={notificationSettings.description}
|
||||
onChange={(e): void =>
|
||||
setNotificationSettings({
|
||||
type: 'SET_DESCRIPTION',
|
||||
payload: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Enter notification message..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationMessage;
|
||||
@ -0,0 +1,112 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Input, Select, Typography } from 'antd';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS as RE_NOTIFICATION_UNIT_OPTIONS,
|
||||
RE_NOTIFICATION_CONDITION_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
|
||||
import Stepper from '../Stepper';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import MultipleNotifications from './MultipleNotifications';
|
||||
import NotificationMessage from './NotificationMessage';
|
||||
|
||||
function NotificationSettings(): JSX.Element {
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const repeatNotificationsInput = (
|
||||
<div className="repeat-notifications-input">
|
||||
<Typography.Text>Every</Typography.Text>
|
||||
<Input
|
||||
value={notificationSettings.reNotification.value}
|
||||
placeholder="Enter time interval..."
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
type="number"
|
||||
onChange={(e): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: parseInt(e.target.value, 10),
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={notificationSettings.reNotification.unit || null}
|
||||
placeholder="Select unit"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_UNIT_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: value,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Typography.Text>while</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={notificationSettings.reNotification.conditions || null}
|
||||
placeholder="Select conditions"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_CONDITION_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="notification-settings-container">
|
||||
<Stepper
|
||||
stepNumber={showCondensedLayoutFlag ? 3 : 4}
|
||||
label="Notification settings"
|
||||
/>
|
||||
<NotificationMessage />
|
||||
<div className="notification-settings-content">
|
||||
<MultipleNotifications />
|
||||
<AdvancedOptionItem
|
||||
title="Repeat notifications"
|
||||
description="Send periodic notifications while the alert condition remains active."
|
||||
tooltipText="Continue sending periodic notifications while the alert condition persists. Useful for ensuring critical alerts aren't missed during long-running incidents. Configure how often to repeat and under what conditions."
|
||||
input={repeatNotificationsInput}
|
||||
onToggle={(): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
...notificationSettings.reNotification,
|
||||
enabled: !notificationSettings.reNotification.enabled,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationSettings;
|
||||
@ -0,0 +1,172 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
import {
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
|
||||
import MultipleNotifications from '../MultipleNotifications';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_QUERY = 'test-query';
|
||||
const TEST_GROUP_BY_FIELDS = [{ key: 'service' }, { key: 'environment' }];
|
||||
const TRUE = 'true';
|
||||
const FALSE = 'false';
|
||||
const COMBOBOX_ROLE = 'combobox';
|
||||
const ARIA_DISABLED_ATTR = 'aria-disabled';
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
const mockUseQueryBuilder = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: TEST_QUERY,
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const initialAlertThresholdState = createMockAlertContextState().thresholdState;
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
thresholdState: {
|
||||
...initialAlertThresholdState,
|
||||
selectedQuery: TEST_QUERY,
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('MultipleNotifications', () => {
|
||||
const { useQueryBuilder } = jest.requireMock(
|
||||
'hooks/queryBuilder/useQueryBuilder',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useQueryBuilder.mockReturnValue(mockUseQueryBuilder);
|
||||
});
|
||||
|
||||
it('should render the multiple notifications component with no grouping fields and disabled input by default', () => {
|
||||
render(<MultipleNotifications />);
|
||||
expect(screen.getByText('Group alerts by')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Combine alerts with the same field values into a single notification.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('No grouping fields available')).toBeInTheDocument();
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, TRUE);
|
||||
});
|
||||
|
||||
it('should render the multiple notifications component with grouping fields and enabled input when space aggregation options are set', () => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: TEST_QUERY,
|
||||
groupBy: TEST_GROUP_BY_FIELDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Empty = all matching alerts combined into one notification',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, FALSE);
|
||||
});
|
||||
|
||||
it('should render the multiple notifications component with grouping fields and enabled input when space aggregation options are set and multiple notifications are enabled', () => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: TEST_QUERY,
|
||||
groupBy: TEST_GROUP_BY_FIELDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
thresholdState: {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
selectedQuery: TEST_QUERY,
|
||||
},
|
||||
notificationSettings: {
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
multipleNotifications: ['service', 'environment'],
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Alerts with same service, environment will be grouped'),
|
||||
).toBeInTheDocument();
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, FALSE);
|
||||
});
|
||||
|
||||
it('should render unique group by options from all queries', async () => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'test-query-1',
|
||||
groupBy: [{ key: 'http.status_code' }],
|
||||
},
|
||||
{
|
||||
queryName: 'test-query-2',
|
||||
groupBy: [{ key: 'service' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
await userEvent.click(select);
|
||||
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'http.status_code' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'service' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,75 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
|
||||
import NotificationMessage from '../NotificationMessage';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
const initialNotificationSettingsState = createMockAlertContextState()
|
||||
.notificationSettings;
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
notificationSettings: {
|
||||
...initialNotificationSettingsState,
|
||||
description: '',
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('NotificationMessage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders textarea with message and placeholder', () => {
|
||||
render(<NotificationMessage />);
|
||||
expect(screen.getByText('Notification Message')).toBeInTheDocument();
|
||||
const textarea = screen.getByPlaceholderText('Enter notification message...');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates notification settings when textarea value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotificationMessage />);
|
||||
const textarea = screen.getByPlaceholderText('Enter notification message...');
|
||||
await user.type(textarea, 'x');
|
||||
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
|
||||
type: 'SET_DESCRIPTION',
|
||||
payload: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
it('displays existing description value', () => {
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
description: 'Existing message',
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
render(<NotificationMessage />);
|
||||
|
||||
const textarea = screen.getByDisplayValue('Existing message');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,120 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import * as utils from 'container/CreateAlertV2/utils';
|
||||
|
||||
import NotificationSettings from '../NotificationSettings';
|
||||
|
||||
jest.mock(
|
||||
'container/CreateAlertV2/NotificationSettings/MultipleNotifications',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="multiple-notifications">MultipleNotifications</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'container/CreateAlertV2/NotificationSettings/NotificationMessage',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="notification-message">NotificationMessage</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const initialNotificationSettings = createMockAlertContextState()
|
||||
.notificationSettings;
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const REPEAT_NOTIFICATIONS_TEXT = 'Repeat notifications';
|
||||
const ENTER_TIME_INTERVAL_TEXT = 'Enter time interval...';
|
||||
|
||||
describe('NotificationSettings', () => {
|
||||
it('renders the notification settings tab with step number 4 and default values', () => {
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText('Notification settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Send periodic notifications while the alert condition remains active.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the notification settings tab with step number 3 in condensed layout', () => {
|
||||
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText('Notification settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Repeat notifications', () => {
|
||||
it('renders the repeat notifications with inputs hidden when the repeat notifications switch is off', () => {
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText('Every')).not.toBeVisible();
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('toggles the repeat notifications switch and shows the inputs', () => {
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Every')).not.toBeVisible();
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT),
|
||||
).not.toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
|
||||
expect(screen.getByText('Every')).toBeVisible();
|
||||
expect(screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT)).toBeVisible();
|
||||
});
|
||||
|
||||
it('updates state when the repeat notifications input is changed', () => {
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
notificationSettings: {
|
||||
...initialNotificationSettings,
|
||||
reNotification: {
|
||||
...initialNotificationSettings.reNotification,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT), {
|
||||
target: { value: '13' },
|
||||
});
|
||||
|
||||
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: true,
|
||||
value: 13,
|
||||
unit: 'min',
|
||||
conditions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
|
||||
export default NotificationSettings;
|
||||
@ -0,0 +1,346 @@
|
||||
.notification-settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 16px;
|
||||
|
||||
.notification-message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: -8px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
|
||||
.notification-message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.notification-message-header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.notification-message-header-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-message-header-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message-header-actions {
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 150px;
|
||||
background: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
.repeat-notifications-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-input {
|
||||
width: 120px;
|
||||
border: 1px solid var(--bg-slate-100);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-multiple {
|
||||
.ant-select-selector {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-container {
|
||||
display: flex;
|
||||
padding: 4px 16px 16px 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
justify-content: space-between;
|
||||
|
||||
.multiple-notifications-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.multiple-notifications-header-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.multiple-notifications-header-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.multiple-notifications-select-description {
|
||||
font-size: 10px;
|
||||
color: var(--bg-vanilla-400);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.re-notification-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
.advanced-option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
width: 100%;
|
||||
margin-left: -16px;
|
||||
margin-right: -32px;
|
||||
}
|
||||
|
||||
.re-notification-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.ant-typography {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--bg-vanilla-400);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 200px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-variable-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.template-variable-content-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
code {
|
||||
background-color: var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.notification-settings-container {
|
||||
.notification-message-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.notification-message-header {
|
||||
.notification-message-header-content {
|
||||
.notification-message-header-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.notification-message-header-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message-header-actions {
|
||||
.ant-btn {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-settings-content {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.repeat-notifications-input {
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.multiple-notifications-header {
|
||||
.multiple-notifications-header-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.multiple-notifications-header-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-select-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.re-notification-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.advanced-option-item {
|
||||
.advanced-option-item-left-content {
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.re-notification-condition {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-variable-content-item {
|
||||
code {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import {
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
@ -170,3 +171,22 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
||||
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
|
||||
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
|
||||
|
||||
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
|
||||
{ value: 'firing', label: 'Firing' },
|
||||
{ value: 'no-data', label: 'No Data' },
|
||||
];
|
||||
|
||||
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
multipleNotifications: [],
|
||||
reNotification: {
|
||||
enabled: false,
|
||||
value: 1,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
conditions: [],
|
||||
},
|
||||
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
|
||||
};
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
@ -27,6 +28,7 @@ import {
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
notificationSettingsReducer,
|
||||
} from './utils';
|
||||
|
||||
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
|
||||
@ -94,6 +96,11 @@ export function CreateAlertProvider(
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
const [notificationSettings, setNotificationSettings] = useReducer(
|
||||
notificationSettingsReducer,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
@ -112,6 +119,8 @@ export function CreateAlertProvider(
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
}),
|
||||
[
|
||||
alertState,
|
||||
@ -120,6 +129,7 @@ export function CreateAlertProvider(
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -14,6 +14,8 @@ export interface ICreateAlertContextProps {
|
||||
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
notificationSettings: NotificationSettingsState;
|
||||
setNotificationSettings: Dispatch<NotificationSettingsAction>;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
@ -38,7 +40,8 @@ export type CreateAlertAction =
|
||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined };
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface Threshold {
|
||||
id: string;
|
||||
@ -190,3 +193,31 @@ export type EvaluationWindowAction =
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
|
||||
export interface NotificationSettingsState {
|
||||
multipleNotifications: string[] | null;
|
||||
reNotification: {
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type NotificationSettingsAction =
|
||||
| {
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS';
|
||||
payload: string[] | null;
|
||||
}
|
||||
| {
|
||||
type: 'SET_RE_NOTIFICATION';
|
||||
payload: {
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
}
|
||||
| { type: 'SET_DESCRIPTION'; payload: string }
|
||||
| { type: 'RESET' };
|
||||
|
||||
@ -13,8 +13,10 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
@ -25,6 +27,8 @@ import {
|
||||
CreateAlertAction,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsAction,
|
||||
NotificationSettingsState,
|
||||
} from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
@ -52,6 +56,8 @@ export const alertCreationReducer = (
|
||||
...state,
|
||||
yAxisUnit: action.payload,
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ALERT_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@ -172,3 +178,21 @@ export const evaluationWindowReducer = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const notificationSettingsReducer = (
|
||||
state: NotificationSettingsState,
|
||||
action: NotificationSettingsAction,
|
||||
): NotificationSettingsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_MULTIPLE_NOTIFICATIONS':
|
||||
return { ...state, multipleNotifications: action.payload };
|
||||
case 'SET_RE_NOTIFICATION':
|
||||
return { ...state, reNotification: action.payload };
|
||||
case 'SET_DESCRIPTION':
|
||||
return { ...state, description: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_NOTIFICATION_SETTINGS_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
// UI side feature flag
|
||||
export const showNewCreateAlertsPage = (): boolean =>
|
||||
localStorage.getItem('showNewCreateAlertsPage') === 'true';
|
||||
|
||||
// UI side FF to switch between the 2 layouts of the create alert page
|
||||
// Layout 1 - Default layout
|
||||
// Layout 2 - Condensed layout
|
||||
export const showCondensedLayout = (): boolean =>
|
||||
localStorage.getItem('showCondensedLayout') === 'true';
|
||||
|
||||
@ -2,14 +2,14 @@
|
||||
import '../OnboardingQuestionaire.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Input, Typography } from 'antd';
|
||||
import { Button, Checkbox, Input, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface SignozDetails {
|
||||
interestInSignoz: string | null;
|
||||
interestInSignoz: string[] | null;
|
||||
otherInterestInSignoz: string | null;
|
||||
discoverSignoz: string | null;
|
||||
}
|
||||
@ -22,9 +22,12 @@ interface AboutSigNozQuestionsProps {
|
||||
}
|
||||
|
||||
const interestedInOptions: Record<string, string> = {
|
||||
savingCosts: 'Saving costs',
|
||||
otelNativeStack: 'Interested in Otel-native stack',
|
||||
allInOne: 'All in one (Logs, Metrics & Traces)',
|
||||
loweringCosts: 'Lowering observability costs',
|
||||
otelNativeStack: 'Interested in OTel-native stack',
|
||||
deploymentFlexibility: 'Deployment flexibility (Cloud/Self-Host) in future',
|
||||
singleTool:
|
||||
'Single Tool (logs, metrics & traces) to reduce operational overhead',
|
||||
correlateSignals: 'Correlate signals for faster troubleshooting',
|
||||
};
|
||||
|
||||
export function AboutSigNozQuestions({
|
||||
@ -33,8 +36,8 @@ export function AboutSigNozQuestions({
|
||||
onNext,
|
||||
onBack,
|
||||
}: AboutSigNozQuestionsProps): JSX.Element {
|
||||
const [interestInSignoz, setInterestInSignoz] = useState<string | null>(
|
||||
signozDetails?.interestInSignoz || null,
|
||||
const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
|
||||
signozDetails?.interestInSignoz || [],
|
||||
);
|
||||
const [otherInterestInSignoz, setOtherInterestInSignoz] = useState<string>(
|
||||
signozDetails?.otherInterestInSignoz || '',
|
||||
@ -47,8 +50,8 @@ export function AboutSigNozQuestions({
|
||||
useEffect((): void => {
|
||||
if (
|
||||
discoverSignoz !== '' &&
|
||||
interestInSignoz !== null &&
|
||||
(interestInSignoz !== 'Others' || otherInterestInSignoz !== '')
|
||||
interestInSignoz.length > 0 &&
|
||||
(!interestInSignoz.includes('Others') || otherInterestInSignoz !== '')
|
||||
) {
|
||||
setIsNextDisabled(false);
|
||||
} else {
|
||||
@ -56,6 +59,14 @@ export function AboutSigNozQuestions({
|
||||
}
|
||||
}, [interestInSignoz, otherInterestInSignoz, discoverSignoz]);
|
||||
|
||||
const handleInterestChange = (option: string, checked: boolean): void => {
|
||||
if (checked) {
|
||||
setInterestInSignoz((prev) => [...prev, option]);
|
||||
} else {
|
||||
setInterestInSignoz((prev) => prev.filter((item) => item !== option));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnNext = (): void => {
|
||||
setSignozDetails({
|
||||
discoverSignoz,
|
||||
@ -108,50 +119,45 @@ export function AboutSigNozQuestions({
|
||||
|
||||
<div className="form-group">
|
||||
<div className="question">What got you interested in SigNoz?</div>
|
||||
<div className="two-column-grid">
|
||||
<div className="checkbox-grid">
|
||||
{Object.keys(interestedInOptions).map((option: string) => (
|
||||
<Button
|
||||
key={option}
|
||||
type="primary"
|
||||
className={`onboarding-questionaire-button ${
|
||||
interestInSignoz === option ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => setInterestInSignoz(option)}
|
||||
>
|
||||
{interestedInOptions[option]}
|
||||
{interestInSignoz === option && (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
)}
|
||||
</Button>
|
||||
<div key={option} className="checkbox-item">
|
||||
<Checkbox
|
||||
checked={interestInSignoz.includes(option)}
|
||||
onChange={(e): void => handleInterestChange(option, e.target.checked)}
|
||||
>
|
||||
{interestedInOptions[option]}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{interestInSignoz === 'Others' ? (
|
||||
<Input
|
||||
type="text"
|
||||
className="onboarding-questionaire-other-input"
|
||||
placeholder="Please specify your interest"
|
||||
value={otherInterestInSignoz}
|
||||
autoFocus
|
||||
addonAfter={
|
||||
otherInterestInSignoz !== '' ? (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
<div className="checkbox-item">
|
||||
<Checkbox
|
||||
checked={interestInSignoz.includes('Others')}
|
||||
onChange={(e): void =>
|
||||
handleInterestChange('Others', e.target.checked)
|
||||
}
|
||||
onChange={(e): void => setOtherInterestInSignoz(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
className={`onboarding-questionaire-button ${
|
||||
interestInSignoz === 'Others' ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => setInterestInSignoz('Others')}
|
||||
>
|
||||
Others
|
||||
</Button>
|
||||
)}
|
||||
</Checkbox>
|
||||
{interestInSignoz.includes('Others') && (
|
||||
<Input
|
||||
type="text"
|
||||
className="onboarding-questionaire-other-input"
|
||||
placeholder="Please specify your interest"
|
||||
value={otherInterestInSignoz}
|
||||
autoFocus
|
||||
addonAfter={
|
||||
otherInterestInSignoz !== '' ? (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onChange={(e): void => setOtherInterestInSignoz(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -94,6 +94,7 @@
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
@ -290,6 +291,37 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.checkbox-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
.ant-checkbox {
|
||||
.ant-checkbox-inner {
|
||||
border-color: var(--bg-slate-100);
|
||||
background-color: var(--bg-ink-200);
|
||||
}
|
||||
|
||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: var(--bg-robin-500);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-questionaire-button,
|
||||
.add-another-member-button,
|
||||
.remove-team-member-button {
|
||||
@ -466,6 +498,7 @@
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-300);
|
||||
font-weight: 400;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-slate-400);
|
||||
@ -527,6 +560,24 @@
|
||||
color: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
.ant-checkbox-wrapper {
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
.ant-checkbox {
|
||||
.ant-checkbox-inner {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: var(--bg-robin-500);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
@ -38,6 +38,7 @@ const observabilityTools = {
|
||||
AzureAppMonitor: 'Azure App Monitor',
|
||||
GCPNativeO11yTools: 'GCP-native o11y tools',
|
||||
Honeycomb: 'Honeycomb',
|
||||
None: 'None/Starting fresh',
|
||||
};
|
||||
|
||||
function OrgQuestions({
|
||||
@ -53,9 +54,6 @@ function OrgQuestions({
|
||||
const [organisationName, setOrganisationName] = useState<string>(
|
||||
orgDetails?.organisationName || '',
|
||||
);
|
||||
const [usesObservability, setUsesObservability] = useState<boolean | null>(
|
||||
orgDetails?.usesObservability || null,
|
||||
);
|
||||
const [observabilityTool, setObservabilityTool] = useState<string | null>(
|
||||
orgDetails?.observabilityTool || null,
|
||||
);
|
||||
@ -83,7 +81,7 @@ function OrgQuestions({
|
||||
orgDetails.organisationName === organisationName
|
||||
) {
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
usesObservability: !observabilityTool?.includes('None'),
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
@ -91,7 +89,7 @@ function OrgQuestions({
|
||||
|
||||
onNext({
|
||||
organisationName,
|
||||
usesObservability,
|
||||
usesObservability: !observabilityTool?.includes('None'),
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
@ -114,7 +112,7 @@ function OrgQuestions({
|
||||
});
|
||||
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
usesObservability: !observabilityTool?.includes('None'),
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
@ -122,7 +120,7 @@ function OrgQuestions({
|
||||
|
||||
onNext({
|
||||
organisationName,
|
||||
usesObservability,
|
||||
usesObservability: !observabilityTool?.includes('None'),
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
@ -152,16 +150,16 @@ function OrgQuestions({
|
||||
};
|
||||
|
||||
const isValidUsesObservability = (): boolean => {
|
||||
if (usesObservability === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (usesObservability && (!observabilityTool || observabilityTool === '')) {
|
||||
if (!observabilityTool || observabilityTool === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
|
||||
if (usesObservability && observabilityTool === 'Others' && otherTool === '') {
|
||||
if (
|
||||
!observabilityTool?.includes('None') &&
|
||||
observabilityTool === 'Others' &&
|
||||
otherTool === ''
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -177,13 +175,7 @@ function OrgQuestions({
|
||||
setIsNextDisabled(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
organisationName,
|
||||
usesObservability,
|
||||
usesOtel,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
]);
|
||||
}, [organisationName, usesOtel, observabilityTool, otherTool]);
|
||||
|
||||
const handleOnNext = (): void => {
|
||||
handleOrgNameUpdate();
|
||||
@ -217,99 +209,57 @@ function OrgQuestions({
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="question" htmlFor="usesObservability">
|
||||
Do you currently use any observability/monitoring tool?
|
||||
<label className="question" htmlFor="observabilityTool">
|
||||
Which observability tool do you currently use?
|
||||
</label>
|
||||
|
||||
<div className="two-column-grid">
|
||||
<Button
|
||||
type="primary"
|
||||
name="usesObservability"
|
||||
className={`onboarding-questionaire-button ${
|
||||
usesObservability === true ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => {
|
||||
setUsesObservability(true);
|
||||
}}
|
||||
>
|
||||
Yes{' '}
|
||||
{usesObservability === true && (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
className={`onboarding-questionaire-button ${
|
||||
usesObservability === false ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => {
|
||||
setUsesObservability(false);
|
||||
setObservabilityTool(null);
|
||||
setOtherTool('');
|
||||
}}
|
||||
>
|
||||
No{' '}
|
||||
{usesObservability === false && (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
)}
|
||||
</Button>
|
||||
{Object.keys(observabilityTools).map((tool) => (
|
||||
<Button
|
||||
key={tool}
|
||||
type="primary"
|
||||
className={`onboarding-questionaire-button ${
|
||||
observabilityTool === tool ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => setObservabilityTool(tool)}
|
||||
>
|
||||
{observabilityTools[tool as keyof typeof observabilityTools]}
|
||||
|
||||
{observabilityTool === tool && (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{observabilityTool === 'Others' ? (
|
||||
<Input
|
||||
type="text"
|
||||
className="onboarding-questionaire-other-input"
|
||||
placeholder="Please specify the tool"
|
||||
value={otherTool || ''}
|
||||
autoFocus
|
||||
addonAfter={
|
||||
otherTool && otherTool !== '' ? (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onChange={(e): void => setOtherTool(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
className={`onboarding-questionaire-button ${
|
||||
observabilityTool === 'Others' ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => setObservabilityTool('Others')}
|
||||
>
|
||||
Others
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usesObservability && (
|
||||
<div className="form-group">
|
||||
<label className="question" htmlFor="observabilityTool">
|
||||
Which observability tool do you currently use?
|
||||
</label>
|
||||
<div className="two-column-grid">
|
||||
{Object.keys(observabilityTools).map((tool) => (
|
||||
<Button
|
||||
key={tool}
|
||||
type="primary"
|
||||
className={`onboarding-questionaire-button ${
|
||||
observabilityTool === tool ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => setObservabilityTool(tool)}
|
||||
>
|
||||
{observabilityTools[tool as keyof typeof observabilityTools]}
|
||||
|
||||
{observabilityTool === tool && (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{observabilityTool === 'Others' ? (
|
||||
<Input
|
||||
type="text"
|
||||
className="onboarding-questionaire-other-input"
|
||||
placeholder="Please specify the tool"
|
||||
value={otherTool || ''}
|
||||
autoFocus
|
||||
addonAfter={
|
||||
otherTool && otherTool !== '' ? (
|
||||
<CheckCircle size={12} color={Color.BG_FOREST_500} />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onChange={(e): void => setOtherTool(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
className={`onboarding-questionaire-button ${
|
||||
observabilityTool === 'Others' ? 'active' : ''
|
||||
}`}
|
||||
onClick={(): void => setObservabilityTool('Others')}
|
||||
>
|
||||
Others
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="question">Do you already use OpenTelemetry?</div>
|
||||
<div className="two-column-grid">
|
||||
|
||||
@ -46,7 +46,7 @@ const INITIAL_ORG_DETAILS: OrgDetails = {
|
||||
};
|
||||
|
||||
const INITIAL_SIGNOZ_DETAILS: SignozDetails = {
|
||||
interestInSignoz: '',
|
||||
interestInSignoz: [],
|
||||
otherInterestInSignoz: '',
|
||||
discoverSignoz: '',
|
||||
};
|
||||
@ -145,6 +145,9 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
|
||||
// Allow user to proceed even if API fails
|
||||
setCurrentStep(4);
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -174,10 +177,16 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
? (orgDetails?.otherTool as string)
|
||||
: (orgDetails?.observabilityTool as string),
|
||||
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
|
||||
reasons_for_interest_in_signoz:
|
||||
signozDetails?.interestInSignoz === 'Others'
|
||||
? (signozDetails?.otherInterestInSignoz as string)
|
||||
: (signozDetails?.interestInSignoz as string),
|
||||
reasons_for_interest_in_signoz: signozDetails?.interestInSignoz?.includes(
|
||||
'Others',
|
||||
)
|
||||
? ([
|
||||
...(signozDetails?.interestInSignoz?.filter(
|
||||
(item) => item !== 'Others',
|
||||
) || []),
|
||||
signozDetails?.otherInterestInSignoz,
|
||||
] as string[])
|
||||
: (signozDetails?.interestInSignoz as string[]),
|
||||
logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number,
|
||||
number_of_hosts: optimiseSignozDetails?.hostsPerDay as number,
|
||||
number_of_services: optimiseSignozDetails?.services as number,
|
||||
|
||||
@ -45,6 +45,13 @@ function UplotPanelWrapper({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
@ -227,6 +234,13 @@ function UplotPanelWrapper({
|
||||
enhancedLegend: true, // Enable enhanced legend
|
||||
legendPosition: widget?.legendPosition,
|
||||
query: widget?.query || currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
queryResponse.data?.payload,
|
||||
|
||||
@ -0,0 +1,218 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { LegendPosition } from 'types/api/dashboard/getAll';
|
||||
|
||||
// Mock uPlot
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('container/PanelWrapper/enhancedLegend', () => ({
|
||||
calculateEnhancedLegendConfig: jest.fn(() => ({
|
||||
minHeight: 46,
|
||||
maxHeight: 80,
|
||||
calculatedHeight: 60,
|
||||
showScrollbar: false,
|
||||
requiredRows: 2,
|
||||
})),
|
||||
applyEnhancedLegendStyling: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockApiResponse = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { __name__: 'test_metric' },
|
||||
queryName: 'test_query',
|
||||
values: [
|
||||
[1640995200, '10'] as [number, string],
|
||||
[1640995260, '20'] as [number, string],
|
||||
],
|
||||
},
|
||||
],
|
||||
resultType: 'time_series',
|
||||
newResult: {
|
||||
data: {
|
||||
result: [],
|
||||
resultType: 'time_series',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockDimensions = { width: 800, height: 400 };
|
||||
|
||||
const baseOptions = {
|
||||
id: 'test-widget',
|
||||
dimensions: mockDimensions,
|
||||
isDarkMode: false,
|
||||
apiResponse: mockApiResponse,
|
||||
enhancedLegend: true,
|
||||
legendPosition: LegendPosition.BOTTOM,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
};
|
||||
|
||||
describe('Legend Scroll Position Preservation', () => {
|
||||
let originalRequestAnimationFrame: typeof global.requestAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
originalRequestAnimationFrame = global.requestAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
it('should set up scroll position tracking in ready hook', () => {
|
||||
const mockSetScrollPosition = jest.fn();
|
||||
const options = getUPlotChartOptions({
|
||||
...baseOptions,
|
||||
setLegendScrollPosition: mockSetScrollPosition,
|
||||
});
|
||||
|
||||
// Create mock chart with legend element
|
||||
const mockChart = {
|
||||
root: document.createElement('div'),
|
||||
} as any;
|
||||
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'u-legend';
|
||||
mockChart.root.appendChild(legend);
|
||||
|
||||
const addEventListenerSpy = jest.spyOn(legend, 'addEventListener');
|
||||
|
||||
// Execute ready hook
|
||||
if (options.hooks?.ready) {
|
||||
options.hooks.ready.forEach((hook) => hook?.(mockChart));
|
||||
}
|
||||
|
||||
// Verify that scroll event listener was added and cleanup function was stored
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'scroll',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockChart._legendScrollCleanup).toBeDefined();
|
||||
});
|
||||
|
||||
it('should restore scroll position when provided', () => {
|
||||
const mockScrollPosition = { scrollTop: 50, scrollLeft: 10 };
|
||||
const mockSetScrollPosition = jest.fn();
|
||||
const options = getUPlotChartOptions({
|
||||
...baseOptions,
|
||||
legendScrollPosition: mockScrollPosition,
|
||||
setLegendScrollPosition: mockSetScrollPosition,
|
||||
});
|
||||
|
||||
// Create mock chart with legend element
|
||||
const mockChart = {
|
||||
root: document.createElement('div'),
|
||||
} as any;
|
||||
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'u-legend';
|
||||
legend.scrollTop = 0;
|
||||
legend.scrollLeft = 0;
|
||||
mockChart.root.appendChild(legend);
|
||||
|
||||
// Mock requestAnimationFrame
|
||||
const mockRequestAnimationFrame = jest.fn((callback) => callback());
|
||||
global.requestAnimationFrame = mockRequestAnimationFrame;
|
||||
|
||||
// Execute ready hook
|
||||
if (options.hooks?.ready) {
|
||||
options.hooks.ready.forEach((hook) => hook?.(mockChart));
|
||||
}
|
||||
|
||||
// Verify that requestAnimationFrame was called to restore scroll position
|
||||
expect(mockRequestAnimationFrame).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
// Verify that the legend's scroll position was actually restored
|
||||
expect(legend.scrollTop).toBe(mockScrollPosition.scrollTop);
|
||||
expect(legend.scrollLeft).toBe(mockScrollPosition.scrollLeft);
|
||||
});
|
||||
|
||||
it('should handle missing scroll position parameters gracefully', () => {
|
||||
const options = getUPlotChartOptions(baseOptions);
|
||||
|
||||
// Should not throw error and should still create valid options
|
||||
expect(options.hooks?.ready).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work for both bottom and right legend positions', () => {
|
||||
const mockSetScrollPosition = jest.fn();
|
||||
const mockScrollPosition = { scrollTop: 30, scrollLeft: 15 };
|
||||
|
||||
// Mock requestAnimationFrame for this test
|
||||
const mockRequestAnimationFrame = jest.fn((callback) => callback());
|
||||
global.requestAnimationFrame = mockRequestAnimationFrame;
|
||||
|
||||
// Test bottom legend position
|
||||
const bottomOptions = getUPlotChartOptions({
|
||||
...baseOptions,
|
||||
legendPosition: LegendPosition.BOTTOM,
|
||||
legendScrollPosition: mockScrollPosition,
|
||||
setLegendScrollPosition: mockSetScrollPosition,
|
||||
});
|
||||
|
||||
// Test right legend position
|
||||
const rightOptions = getUPlotChartOptions({
|
||||
...baseOptions,
|
||||
legendPosition: LegendPosition.RIGHT,
|
||||
legendScrollPosition: mockScrollPosition,
|
||||
setLegendScrollPosition: mockSetScrollPosition,
|
||||
});
|
||||
|
||||
// Both should have ready hooks
|
||||
expect(bottomOptions.hooks?.ready).toBeDefined();
|
||||
expect(rightOptions.hooks?.ready).toBeDefined();
|
||||
|
||||
// Test bottom legend scroll restoration
|
||||
const bottomChart = {
|
||||
root: document.createElement('div'),
|
||||
} as any;
|
||||
const bottomLegend = document.createElement('div');
|
||||
bottomLegend.className = 'u-legend';
|
||||
bottomLegend.scrollTop = 0;
|
||||
bottomLegend.scrollLeft = 0;
|
||||
bottomChart.root.appendChild(bottomLegend);
|
||||
|
||||
// Execute bottom legend ready hook
|
||||
if (bottomOptions.hooks?.ready) {
|
||||
bottomOptions.hooks.ready.forEach((hook) => hook?.(bottomChart));
|
||||
}
|
||||
|
||||
expect(bottomLegend.scrollTop).toBe(mockScrollPosition.scrollTop);
|
||||
expect(bottomLegend.scrollLeft).toBe(mockScrollPosition.scrollLeft);
|
||||
|
||||
// Test right legend scroll restoration
|
||||
const rightChart = {
|
||||
root: document.createElement('div'),
|
||||
} as any;
|
||||
const rightLegend = document.createElement('div');
|
||||
rightLegend.className = 'u-legend';
|
||||
rightLegend.scrollTop = 0;
|
||||
rightLegend.scrollLeft = 0;
|
||||
rightChart.root.appendChild(rightLegend);
|
||||
|
||||
// Execute right legend ready hook
|
||||
if (rightOptions.hooks?.ready) {
|
||||
rightOptions.hooks.ready.forEach((hook) => hook?.(rightChart));
|
||||
}
|
||||
|
||||
expect(rightLegend.scrollTop).toBe(mockScrollPosition.scrollTop);
|
||||
expect(rightLegend.scrollLeft).toBe(mockScrollPosition.scrollLeft);
|
||||
});
|
||||
});
|
||||
@ -32,6 +32,12 @@ import getSeries from './utils/getSeriesData';
|
||||
import { getXAxisScale } from './utils/getXAxisScale';
|
||||
import { getYAxisScale } from './utils/getYAxisScale';
|
||||
|
||||
// Extended uPlot interface with custom properties
|
||||
interface ExtendedUPlot extends uPlot {
|
||||
_legendScrollCleanup?: () => void;
|
||||
_tooltipCleanup?: () => void;
|
||||
}
|
||||
|
||||
export interface GetUPlotChartOptions {
|
||||
id?: string;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
@ -72,6 +78,14 @@ export interface GetUPlotChartOptions {
|
||||
legendPosition?: LegendPosition;
|
||||
enableZoom?: boolean;
|
||||
query?: Query;
|
||||
legendScrollPosition?: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
};
|
||||
setLegendScrollPosition?: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/** the function converts series A , series B , series C to
|
||||
@ -201,6 +215,8 @@ export const getUPlotChartOptions = ({
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
enableZoom,
|
||||
query,
|
||||
legendScrollPosition,
|
||||
setLegendScrollPosition,
|
||||
}: GetUPlotChartOptions): uPlot.Options => {
|
||||
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
|
||||
|
||||
@ -455,16 +471,43 @@ export const getUPlotChartOptions = ({
|
||||
|
||||
const legend = self.root.querySelector('.u-legend');
|
||||
if (legend) {
|
||||
const legendElement = legend as HTMLElement;
|
||||
|
||||
// Apply enhanced legend styling
|
||||
if (enhancedLegend) {
|
||||
applyEnhancedLegendStyling(
|
||||
legend as HTMLElement,
|
||||
legendElement,
|
||||
legendConfig,
|
||||
legendConfig.requiredRows,
|
||||
legendPosition,
|
||||
);
|
||||
}
|
||||
|
||||
// Restore scroll position if available
|
||||
if (legendScrollPosition && setLegendScrollPosition) {
|
||||
requestAnimationFrame(() => {
|
||||
legendElement.scrollTop = legendScrollPosition.scrollTop;
|
||||
legendElement.scrollLeft = legendScrollPosition.scrollLeft;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up scroll position tracking
|
||||
if (setLegendScrollPosition) {
|
||||
const handleScroll = (): void => {
|
||||
setLegendScrollPosition({
|
||||
scrollTop: legendElement.scrollTop,
|
||||
scrollLeft: legendElement.scrollLeft,
|
||||
});
|
||||
};
|
||||
|
||||
legendElement.addEventListener('scroll', handleScroll);
|
||||
|
||||
// Store cleanup function
|
||||
(self as ExtendedUPlot)._legendScrollCleanup = (): void => {
|
||||
legendElement.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}
|
||||
|
||||
// Global cleanup function for all legend tooltips
|
||||
const cleanupAllTooltips = (): void => {
|
||||
const existingTooltips = document.querySelectorAll('.legend-tooltip');
|
||||
@ -485,7 +528,7 @@ export const getUPlotChartOptions = ({
|
||||
document?.addEventListener('mousemove', globalCleanupHandler);
|
||||
|
||||
// Store cleanup function for potential removal later
|
||||
(self as any)._tooltipCleanup = (): void => {
|
||||
(self as ExtendedUPlot)._tooltipCleanup = (): void => {
|
||||
cleanupAllTooltips();
|
||||
document?.removeEventListener('mousemove', globalCleanupHandler);
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export interface UpdateProfileProps {
|
||||
reasons_for_interest_in_signoz: string;
|
||||
reasons_for_interest_in_signoz: string[];
|
||||
uses_otel: boolean;
|
||||
has_existing_observability_tool: boolean;
|
||||
existing_observability_tool: string;
|
||||
|
||||
@ -68,7 +68,7 @@ export type FunctionName =
|
||||
| 'runningDiff'
|
||||
| 'log2'
|
||||
| 'log10'
|
||||
| 'cumSum'
|
||||
| 'cumulativeSum'
|
||||
| 'ewma3'
|
||||
| 'ewma5'
|
||||
| 'ewma7'
|
||||
|
||||
@ -200,7 +200,7 @@ export enum QueryFunctionsTypes {
|
||||
RUNNING_DIFF = 'runningDiff',
|
||||
LOG_2 = 'log2',
|
||||
LOG_10 = 'log10',
|
||||
CUMULATIVE_SUM = 'cumSum',
|
||||
CUMULATIVE_SUM = 'cumulativeSum',
|
||||
EWMA_3 = 'ewma3',
|
||||
EWMA_5 = 'ewma5',
|
||||
EWMA_7 = 'ewma7',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user