Compare commits
2 Commits
v0.101.0
...
evaluation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c90440f3 | ||
|
|
4e93d8df57 |
@@ -139,6 +139,7 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
"style-loader": "1.3.0",
|
||||
"styled-components": "^5.3.11",
|
||||
|
||||
@@ -119,7 +119,9 @@ const filterAndSortTimezones = (
|
||||
return createTimezoneEntry(normalizedTz, offset);
|
||||
});
|
||||
|
||||
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
|
||||
export const generateTimezoneData = (
|
||||
includeEtcTimezones = false,
|
||||
): Timezone[] => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
|
||||
const timezones: Timezone[] = [];
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/* eslint-disable jsx-a11y/interactive-supports-focus */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
} from './constants';
|
||||
import TimeInput from './TimeInput';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
IEvaluationWindowDetailsProps,
|
||||
IEvaluationWindowPopoverProps,
|
||||
RollingWindowTimeframes,
|
||||
} from './types';
|
||||
import { TIMEZONE_DATA } 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;
|
||||
}, []);
|
||||
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: value,
|
||||
timezone: evaluationWindow.startingAt.timezone,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimezoneChange = (value: string): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: {
|
||||
number: evaluationWindow.startingAt.number,
|
||||
time: evaluationWindow.startingAt.time,
|
||||
timezone: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<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">
|
||||
<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">
|
||||
<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 />;
|
||||
}
|
||||
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const renderEvaluationWindowContent = (
|
||||
label: string,
|
||||
contentOptions: Array<{ label: string; value: string }>,
|
||||
currentValue: string,
|
||||
onChange: (value: string) => void,
|
||||
): JSX.Element => (
|
||||
<div className="evaluation-window-content-item">
|
||||
<Typography.Text className="evaluation-window-content-item-label">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="evaluation-window-content-list">
|
||||
{contentOptions.map((option) => (
|
||||
<div
|
||||
className={classNames('evaluation-window-content-list-item', {
|
||||
active: currentValue === option.value,
|
||||
})}
|
||||
key={option.value}
|
||||
role="button"
|
||||
onClick={(): void => onChange(option.value)}
|
||||
>
|
||||
<Typography.Text>{option.label}</Typography.Text>
|
||||
{currentValue === option.value && <Check size={12} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectionContent = (): JSX.Element => {
|
||||
if (evaluationWindow.windowType === 'rolling') {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Rolling Window has a fixed size and shifts its starting point over time
|
||||
based on when the rules are evaluated.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationWindow.windowType === 'cumulative' &&
|
||||
!evaluationWindow.timeframe
|
||||
) {
|
||||
return (
|
||||
<div className="selection-content">
|
||||
<Typography.Text>
|
||||
A Cumulative Window has a fixed starting point and expands over time.
|
||||
</Typography.Text>
|
||||
<Button type="link">Read the docs</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EvaluationWindowDetails
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="evaluation-window-popover">
|
||||
<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',
|
||||
}),
|
||||
)}
|
||||
{renderEvaluationWindowContent(
|
||||
'TIMEFRAME',
|
||||
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
|
||||
evaluationWindow.timeframe,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
|
||||
}),
|
||||
)}
|
||||
{renderSelectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EvaluationWindowPopover;
|
||||
@@ -0,0 +1,51 @@
|
||||
.time-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
|
||||
.time-input-field {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import './TimeInput.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function TimeInput({
|
||||
value = '00:00:00',
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: TimeInputProps): JSX.Element {
|
||||
const [hours, setHours] = useState('00');
|
||||
const [minutes, setMinutes] = useState('00');
|
||||
const [seconds, setSeconds] = useState('00');
|
||||
|
||||
// Parse initial value
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const timeParts = value.split(':');
|
||||
if (timeParts.length === 3) {
|
||||
setHours(timeParts[0].padStart(2, '0'));
|
||||
setMinutes(timeParts[1].padStart(2, '0'));
|
||||
setSeconds(timeParts[2].padStart(2, '0'));
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Format time value
|
||||
const formatTimeValue = (h: string, m: string, s: string): string =>
|
||||
`${h.padStart(2, '0')}:${m.padStart(2, '0')}:${s.padStart(2, '0')}`;
|
||||
|
||||
// Handle input change
|
||||
const handleTimeChange = (
|
||||
newHours: string,
|
||||
newMinutes: string,
|
||||
newSeconds: string,
|
||||
): void => {
|
||||
const formattedValue = formatTimeValue(newHours, newMinutes, newSeconds);
|
||||
onChange?.(formattedValue);
|
||||
};
|
||||
|
||||
// Handle hours change
|
||||
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newHours = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
setHours(newHours);
|
||||
handleTimeChange(newHours, minutes, seconds);
|
||||
};
|
||||
|
||||
// Handle minutes change
|
||||
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newMinutes = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
setMinutes(newMinutes);
|
||||
handleTimeChange(hours, newMinutes, seconds);
|
||||
};
|
||||
|
||||
// Handle seconds change
|
||||
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newSeconds = e.target.value.replace(/\D/g, '').slice(0, 2);
|
||||
setSeconds(newSeconds);
|
||||
handleTimeChange(hours, minutes, newSeconds);
|
||||
};
|
||||
|
||||
// Helper functions for field navigation
|
||||
const getNextField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'hours':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'seconds';
|
||||
default:
|
||||
return 'hours';
|
||||
}
|
||||
};
|
||||
|
||||
const getPrevField = (current: string): string => {
|
||||
switch (current) {
|
||||
case 'seconds':
|
||||
return 'minutes';
|
||||
case 'minutes':
|
||||
return 'hours';
|
||||
default:
|
||||
return 'seconds';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle key navigation
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
currentField: 'hours' | 'minutes' | 'seconds',
|
||||
): void => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const nextField = document.querySelector(
|
||||
`[data-field="${getNextField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
nextField?.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prevField = document.querySelector(
|
||||
`[data-field="${getPrevField(currentField)}"]`,
|
||||
) as HTMLInputElement;
|
||||
prevField?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`time-input-container ${className}`}>
|
||||
<Input
|
||||
data-field="hours"
|
||||
value={hours}
|
||||
onChange={handleHoursChange}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="minutes"
|
||||
value={minutes}
|
||||
onChange={handleMinutesChange}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
data-field="seconds"
|
||||
value={seconds}
|
||||
onChange={handleSecondsChange}
|
||||
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
|
||||
disabled={disabled}
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TimeInput.defaultProps = {
|
||||
value: '00:00:00',
|
||||
onChange: undefined,
|
||||
disabled: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,3 @@
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
export default TimeInput;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import TimeInput from '../TimeInput/TimeInput';
|
||||
|
||||
describe('TimeInput', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with default value', () => {
|
||||
render(<TimeInput />);
|
||||
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds
|
||||
});
|
||||
|
||||
it('should render with provided value', () => {
|
||||
render(<TimeInput value="12:34:56" />);
|
||||
|
||||
expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours
|
||||
expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes
|
||||
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
|
||||
});
|
||||
|
||||
it('should handle value changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '12' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle minutes changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
fireEvent.change(minutesInput, { target: { value: '30' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:30:00');
|
||||
});
|
||||
|
||||
it('should handle seconds changes', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
fireEvent.change(secondsInput, { target: { value: '45' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
|
||||
});
|
||||
|
||||
it('should pad single digits with zeros', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '5' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('05:00:00');
|
||||
});
|
||||
|
||||
it('should filter non-numeric characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '1a2b3c' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should limit input to 2 characters', () => {
|
||||
render(<TimeInput onChange={mockOnChange} />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
fireEvent.change(hoursInput, { target: { value: '123456' } });
|
||||
|
||||
expect(hoursInput).toHaveValue('12');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowRight', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with ArrowLeft', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(minutesInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should handle Tab navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const minutesInput = screen.getAllByDisplayValue('00')[1];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{Tab}');
|
||||
|
||||
expect(minutesInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from seconds to hours', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(secondsInput);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(hoursInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should wrap around navigation from hours to seconds', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeInput />);
|
||||
|
||||
const hoursInput = screen.getAllByDisplayValue('00')[0];
|
||||
const secondsInput = screen.getAllByDisplayValue('00')[2];
|
||||
|
||||
await user.click(hoursInput);
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(secondsInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<TimeInput className="custom-class" />);
|
||||
|
||||
expect(container.firstChild).toHaveClass(
|
||||
'time-input-container',
|
||||
'custom-class',
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable inputs when disabled prop is true', () => {
|
||||
render(<TimeInput disabled />);
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update internal state when value prop changes', () => {
|
||||
const { rerender } = render(<TimeInput value="01:02:03" />);
|
||||
|
||||
expect(screen.getByDisplayValue('01')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('02')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('03')).toBeInTheDocument();
|
||||
|
||||
rerender(<TimeInput value="04:05:06" />);
|
||||
|
||||
expect(screen.getByDisplayValue('04')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('05')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle malformed time values gracefully', () => {
|
||||
render(<TimeInput value="invalid:time:format" />);
|
||||
|
||||
// Should show the invalid values as they are
|
||||
expect(screen.getByDisplayValue('invalid')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('time')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle partial time values', () => {
|
||||
render(<TimeInput value="12:34" />);
|
||||
|
||||
// Should fall back to default values for incomplete format
|
||||
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { rrulestr } from 'rrule';
|
||||
|
||||
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from '../types';
|
||||
import {
|
||||
buildAlertScheduleFromCustomSchedule,
|
||||
buildAlertScheduleFromRRule,
|
||||
getCumulativeWindowTimeframeText,
|
||||
getEvaluationWindowTypeText,
|
||||
getRollingWindowTimeframeText,
|
||||
getTimeframeText,
|
||||
isValidRRule,
|
||||
} from '../utils';
|
||||
|
||||
const MOCK_DATE_STRING = '2024-01-15T10:30:00Z';
|
||||
const FREQ_DAILY = 'FREQ=DAILY';
|
||||
const TEN_THIRTY_TIME = '10:30:00';
|
||||
const NINE_AM_TIME = '09:00:00';
|
||||
|
||||
// Mock dayjs
|
||||
jest.mock('dayjs', () => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = jest.fn((date?: string | Date) => {
|
||||
if (date) {
|
||||
return originalDayjs(date);
|
||||
}
|
||||
return originalDayjs(MOCK_DATE_STRING);
|
||||
});
|
||||
Object.keys(originalDayjs).forEach((key) => {
|
||||
((mockDayjs as unknown) as Record<string, unknown>)[key] = originalDayjs[key];
|
||||
});
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
jest.mock('rrule', () => ({
|
||||
rrulestr: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('components/CustomTimePicker/timezoneUtils', () => ({
|
||||
generateTimezoneData: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
describe('utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getEvaluationWindowTypeText', () => {
|
||||
it('should return correct text for rolling window', () => {
|
||||
expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling');
|
||||
});
|
||||
|
||||
it('should return correct text for cumulative window', () => {
|
||||
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
|
||||
});
|
||||
|
||||
it('should default to Rolling for unknown type', () => {
|
||||
expect(
|
||||
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
|
||||
).toBe('Rolling');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCumulativeWindowTimeframeText', () => {
|
||||
it('should return correct text for current hour', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_HOUR),
|
||||
).toBe('Current hour');
|
||||
});
|
||||
|
||||
it('should return correct text for current day', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_DAY),
|
||||
).toBe('Current day');
|
||||
});
|
||||
|
||||
it('should return correct text for current month', () => {
|
||||
expect(
|
||||
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_MONTH),
|
||||
).toBe('Current month');
|
||||
});
|
||||
|
||||
it('should default to Current hour for unknown timeframe', () => {
|
||||
expect(getCumulativeWindowTimeframeText('unknown')).toBe('Current hour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRollingWindowTimeframeText', () => {
|
||||
it('should return correct text for last 5 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES),
|
||||
).toBe('Last 5 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 10 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES),
|
||||
).toBe('Last 10 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 15 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES),
|
||||
).toBe('Last 15 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 30 minutes', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES),
|
||||
).toBe('Last 30 minutes');
|
||||
});
|
||||
|
||||
it('should return correct text for last 1 hour', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR),
|
||||
).toBe('Last 1 hour');
|
||||
});
|
||||
|
||||
it('should return correct text for last 2 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS),
|
||||
).toBe('Last 2 hours');
|
||||
});
|
||||
|
||||
it('should return correct text for last 4 hours', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS),
|
||||
).toBe('Last 4 hours');
|
||||
});
|
||||
|
||||
it('should default to Last 5 minutes for unknown timeframe', () => {
|
||||
expect(
|
||||
getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes),
|
||||
).toBe('Last 5 minutes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeframeText', () => {
|
||||
it('should return rolling window text for rolling type', () => {
|
||||
expect(
|
||||
getTimeframeText('rolling', RollingWindowTimeframes.LAST_1_HOUR),
|
||||
).toBe('Last 1 hour');
|
||||
});
|
||||
|
||||
it('should return cumulative window text for cumulative type', () => {
|
||||
expect(
|
||||
getTimeframeText('cumulative', CumulativeWindowTimeframes.CURRENT_DAY),
|
||||
).toBe('Current day');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromRRule', () => {
|
||||
const mockRRule = {
|
||||
all: jest.fn((callback) => {
|
||||
const dates = [
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
];
|
||||
dates.forEach((date, index) => callback(date, index));
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue(mockRRule);
|
||||
});
|
||||
|
||||
it('should return null for empty rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule('', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should build schedule from valid rrule string', () => {
|
||||
const result = buildAlertScheduleFromRRule(
|
||||
FREQ_DAILY,
|
||||
null,
|
||||
TEN_THIRTY_TIME,
|
||||
);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toEqual([
|
||||
new Date(MOCK_DATE_STRING),
|
||||
new Date('2024-01-16T10:30:00Z'),
|
||||
new Date('2024-01-17T10:30:00Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle rrule with DTSTART', () => {
|
||||
const date = dayjs('2024-01-20');
|
||||
buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME);
|
||||
|
||||
// When date is provided, DTSTART is automatically added to the rrule string
|
||||
expect(rrulestr).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/DTSTART:20240120T\d{6}Z/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rrule without DTSTART', () => {
|
||||
// Test with no date provided - should use original rrule string
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME);
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00');
|
||||
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should limit occurrences to maxOccurrences', () => {
|
||||
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAlertScheduleFromCustomSchedule', () => {
|
||||
beforeEach(() => {
|
||||
// Mock dayjs timezone methods
|
||||
((dayjs as unknown) as { tz: jest.Mock }).tz = jest.fn(
|
||||
(date?: string | Date) => {
|
||||
const originalDayjs = jest.requireActual('dayjs');
|
||||
const mockDayjs = originalDayjs(date || MOCK_DATE_STRING);
|
||||
mockDayjs.startOf = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.add = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.date = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.hour = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.minute = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.second = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.daysInMonth = jest.fn().mockReturnValue(31);
|
||||
mockDayjs.day = jest.fn().mockReturnValue(mockDayjs);
|
||||
mockDayjs.isAfter = jest.fn().mockReturnValue(true);
|
||||
mockDayjs.toDate = jest.fn().mockReturnValue(new Date(MOCK_DATE_STRING));
|
||||
return mockDayjs;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for missing required parameters', () => {
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('', [], '10:30:00', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', [], '10:30:00', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', ['monday'], '', 'UTC'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAlertScheduleFromCustomSchedule('week', ['monday'], '10:30:00', ''),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should generate monthly occurrences', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'month',
|
||||
['1', '15'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate weekly occurrences', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'week',
|
||||
['monday', 'friday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter invalid days for monthly schedule', () => {
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'month',
|
||||
['1', 'invalid', '15'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter invalid weekdays for weekly schedule', () => {
|
||||
buildAlertScheduleFromCustomSchedule(
|
||||
'week',
|
||||
['monday', 'invalid', 'friday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
|
||||
// Function should handle invalid weekdays gracefully
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
// Test with invalid parameters that should cause an error
|
||||
const result = buildAlertScheduleFromCustomSchedule(
|
||||
'invalid_repeat_type',
|
||||
['monday'],
|
||||
'10:30:00',
|
||||
'UTC',
|
||||
5,
|
||||
);
|
||||
// Should return empty array, not null, for invalid repeat type
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidRRule', () => {
|
||||
beforeEach(() => {
|
||||
(rrulestr as jest.Mock).mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should return true for valid rrule', () => {
|
||||
expect(isValidRRule(FREQ_DAILY)).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
|
||||
});
|
||||
|
||||
it('should handle escaped newlines', () => {
|
||||
expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true);
|
||||
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
|
||||
});
|
||||
|
||||
it('should return false for invalid rrule', () => {
|
||||
(rrulestr as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Invalid rrule');
|
||||
});
|
||||
|
||||
expect(isValidRRule('INVALID')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
export const EVALUATION_WINDOW_TYPE = [
|
||||
{ label: 'Rolling', value: 'rolling' },
|
||||
{ label: 'Cumulative', value: 'cumulative' },
|
||||
];
|
||||
|
||||
export const EVALUATION_WINDOW_TIMEFRAME = {
|
||||
rolling: [
|
||||
{ label: 'Last 5 minutes', value: '5m0s' },
|
||||
{ label: 'Last 10 minutes', value: '10m0s' },
|
||||
{ label: 'Last 15 minutes', value: '15m0s' },
|
||||
{ label: 'Last 30 minutes', value: '30m0s' },
|
||||
{ label: 'Last 1 hour', value: '1h0m0s' },
|
||||
{ label: 'Last 2 hours', value: '2h0m0s' },
|
||||
{ label: 'Last 4 hours', value: '4h0m0s' },
|
||||
],
|
||||
cumulative: [
|
||||
{ label: 'Current hour', value: 'currentHour' },
|
||||
{ label: 'Current day', value: 'currentDay' },
|
||||
{ label: 'Current month', value: 'currentMonth' },
|
||||
],
|
||||
};
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
|
||||
{ label: 'WEEK', value: 'week' },
|
||||
{ label: 'MONTH', value: 'month' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
|
||||
{ label: 'SUNDAY', value: 'sunday' },
|
||||
{ label: 'MONDAY', value: 'monday' },
|
||||
{ label: 'TUESDAY', value: 'tuesday' },
|
||||
{ label: 'WEDNESDAY', value: 'wednesday' },
|
||||
{ label: 'THURSDAY', value: 'thursday' },
|
||||
{ label: 'FRIDAY', value: 'friday' },
|
||||
{ label: 'SATURDAY', value: 'saturday' },
|
||||
];
|
||||
|
||||
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => {
|
||||
const value = String(i + 1);
|
||||
return { label: value, value };
|
||||
},
|
||||
);
|
||||
|
||||
export const WEEKDAY_MAP: { [key: string]: number } = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
};
|
||||
@@ -0,0 +1,677 @@
|
||||
.evaluation-settings-container {
|
||||
margin: 16px;
|
||||
|
||||
.evaluate-alert-conditions-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background-color: var(--bg-ink-400);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1px dashed var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-options-container {
|
||||
.ant-collapse {
|
||||
.ant-collapse-item {
|
||||
.ant-collapse-header {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
.ant-collapse-header-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
.ant-collapse-content-box {
|
||||
background-color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.advanced-option-item-input {
|
||||
margin-top: 16px;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-right-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.advanced-option-item-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-input {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-option-item-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-ink-200);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popover-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-popover-content {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
margin: 10px;
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
.evaluation-window-popover {
|
||||
min-width: 500px;
|
||||
|
||||
.evaluation-window-content {
|
||||
display: flex;
|
||||
|
||||
.evaluation-window-content-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
padding: 12px 16px;
|
||||
min-width: 250px;
|
||||
min-height: 300px;
|
||||
|
||||
.evaluation-window-content-item-label {
|
||||
color: var(--bg-slate-50);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.evaluation-window-content-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.evaluation-window-content-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 -16px;
|
||||
padding: 4px 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--bg-slate-500);
|
||||
border-left: 2px solid var(--bg-robin-500);
|
||||
.ant-typography {
|
||||
font-weight: 500;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 400px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-ink-300);
|
||||
border-top: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
background-color: var(--bg-ink-200);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-window-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 400px;
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-50);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.time-select-group {
|
||||
.ant-input-group {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
.ant-select {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 60%;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-container {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
.evaluation-cadence-item {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.edit-custom-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
|
||||
.highlight {
|
||||
background-color: var(--bg-slate-500);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 500;
|
||||
margin: 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-group {
|
||||
.ant-btn {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details {
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
.evaluation-cadence-details-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-left: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.query-section-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.query-section-query-actions {
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
flex-direction: row;
|
||||
border-bottom: none;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.explorer-view-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
border: none;
|
||||
padding: 9px;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
border-left: 0.5px solid var(--bg-slate-400);
|
||||
border-bottom: 0.5px solid var(--bg-slate-400);
|
||||
width: 120px;
|
||||
height: 36px;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--bg-ink-500);
|
||||
border-bottom: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-ink-300);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
border-left: 1px solid transparent !important;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-cadence-details-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
|
||||
.evaluation-cadence-details-content-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
height: 500px;
|
||||
overflow-y: scroll;
|
||||
padding-right: 16px;
|
||||
|
||||
.editor-view,
|
||||
.rrule-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
textarea {
|
||||
height: 200px;
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
font-family: 'Space Mono';
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-picker-input {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.no-schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.schedule-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
|
||||
.schedule-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.schedule-preview-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
|
||||
.schedule-preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
|
||||
.schedule-preview-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 20px;
|
||||
|
||||
.schedule-preview-timeline-line {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-preview-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
|
||||
.schedule-preview-date {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-preview-separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1px dashed var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.schedule-preview-timezone {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-date-panel {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-layout {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.ant-picker-date-panel-header {
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from '../context/types';
|
||||
|
||||
export interface IAdvancedOptionItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
input: JSX.Element;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
'LAST_5_MINUTES' = '5m0s',
|
||||
'LAST_10_MINUTES' = '10m0s',
|
||||
'LAST_15_MINUTES' = '15m0s',
|
||||
'LAST_30_MINUTES' = '30m0s',
|
||||
'LAST_1_HOUR' = '1h0m0s',
|
||||
'LAST_2_HOURS' = '2h0m0s',
|
||||
'LAST_4_HOURS' = '4h0m0s',
|
||||
}
|
||||
|
||||
export enum CumulativeWindowTimeframes {
|
||||
'CURRENT_HOUR' = 'currentHour',
|
||||
'CURRENT_DAY' = 'currentDay',
|
||||
'CURRENT_MONTH' = 'currentMonth',
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowPopoverProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowDetailsProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface IEvaluationCadenceDetailsProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export interface TimeInputProps {
|
||||
value?: string; // Format: "HH:MM:SS"
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { rrulestr } from 'rrule';
|
||||
|
||||
import { WEEKDAY_MAP } from './constants';
|
||||
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types';
|
||||
|
||||
// Extend dayjs with timezone plugins
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const getEvaluationWindowTypeText = (
|
||||
windowType: 'rolling' | 'cumulative',
|
||||
): string => {
|
||||
switch (windowType) {
|
||||
case 'rolling':
|
||||
return 'Rolling';
|
||||
case 'cumulative':
|
||||
return 'Cumulative';
|
||||
default:
|
||||
return 'Rolling';
|
||||
}
|
||||
};
|
||||
|
||||
export const getCumulativeWindowTimeframeText = (timeframe: string): string => {
|
||||
switch (timeframe) {
|
||||
case CumulativeWindowTimeframes.CURRENT_HOUR:
|
||||
return 'Current hour';
|
||||
case CumulativeWindowTimeframes.CURRENT_DAY:
|
||||
return 'Current day';
|
||||
case CumulativeWindowTimeframes.CURRENT_MONTH:
|
||||
return 'Current month';
|
||||
default:
|
||||
return 'Current hour';
|
||||
}
|
||||
};
|
||||
|
||||
export const getRollingWindowTimeframeText = (
|
||||
timeframe: RollingWindowTimeframes,
|
||||
): string => {
|
||||
switch (timeframe) {
|
||||
case RollingWindowTimeframes.LAST_5_MINUTES:
|
||||
return 'Last 5 minutes';
|
||||
case RollingWindowTimeframes.LAST_10_MINUTES:
|
||||
return 'Last 10 minutes';
|
||||
case RollingWindowTimeframes.LAST_15_MINUTES:
|
||||
return 'Last 15 minutes';
|
||||
case RollingWindowTimeframes.LAST_30_MINUTES:
|
||||
return 'Last 30 minutes';
|
||||
case RollingWindowTimeframes.LAST_1_HOUR:
|
||||
return 'Last 1 hour';
|
||||
case RollingWindowTimeframes.LAST_2_HOURS:
|
||||
return 'Last 2 hours';
|
||||
case RollingWindowTimeframes.LAST_4_HOURS:
|
||||
return 'Last 4 hours';
|
||||
default:
|
||||
return 'Last 5 minutes';
|
||||
}
|
||||
};
|
||||
|
||||
export const getTimeframeText = (
|
||||
windowType: 'rolling' | 'cumulative',
|
||||
timeframe: string,
|
||||
): string => {
|
||||
if (windowType === 'rolling') {
|
||||
return getRollingWindowTimeframeText(timeframe as RollingWindowTimeframes);
|
||||
}
|
||||
return getCumulativeWindowTimeframeText(timeframe);
|
||||
};
|
||||
|
||||
export function buildAlertScheduleFromRRule(
|
||||
rruleString: string,
|
||||
date: Dayjs | null,
|
||||
startAt: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
if (!rruleString) return null;
|
||||
|
||||
// Handle literal \n in string
|
||||
let finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
|
||||
if (date) {
|
||||
const dt = dayjs(date);
|
||||
if (!dt.isValid()) throw new Error('Invalid date provided');
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
|
||||
const dtWithTime = dt
|
||||
.set('hour', hours)
|
||||
.set('minute', minutes)
|
||||
.set('second', seconds)
|
||||
.set('millisecond', 0);
|
||||
|
||||
const dtStartStr = dtWithTime
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
if (!/DTSTART/i.test(finalRRuleString)) {
|
||||
finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const rruleObj = rrulestr(finalRRuleString);
|
||||
const occurrences: Date[] = [];
|
||||
rruleObj.all((date, index) => {
|
||||
if (index >= maxOccurrences) return false;
|
||||
occurrences.push(date);
|
||||
return true;
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
} catch (error) {
|
||||
console.error('Error building RRULE:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateMonthlyOccurrences(
|
||||
targetDays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
timezone: string,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentMonth = dayjs().tz(timezone).startOf('month');
|
||||
|
||||
Array.from({ length: maxOccurrences }).forEach((_, monthOffset) => {
|
||||
const monthDate = currentMonth.add(monthOffset, 'month');
|
||||
targetDays.forEach((day) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const daysInMonth = monthDate.daysInMonth();
|
||||
if (day <= daysInMonth) {
|
||||
const targetDate = monthDate
|
||||
.date(day)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(dayjs().tz(timezone))) {
|
||||
occurrences.push(targetDate.toDate());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
function generateWeeklyOccurrences(
|
||||
targetWeekdays: number[],
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
timezone: string,
|
||||
maxOccurrences: number,
|
||||
): Date[] {
|
||||
const occurrences: Date[] = [];
|
||||
const currentWeek = dayjs().tz(timezone).startOf('week');
|
||||
|
||||
Array.from({ length: maxOccurrences }).forEach((_, weekOffset) => {
|
||||
const weekDate = currentWeek.add(weekOffset, 'week');
|
||||
targetWeekdays.forEach((weekday) => {
|
||||
if (occurrences.length >= maxOccurrences) return;
|
||||
|
||||
const targetDate = weekDate
|
||||
.day(weekday)
|
||||
.hour(hours)
|
||||
.minute(minutes)
|
||||
.second(seconds);
|
||||
if (targetDate.isAfter(dayjs().tz(timezone))) {
|
||||
occurrences.push(targetDate.toDate());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
export function buildAlertScheduleFromCustomSchedule(
|
||||
repeatEvery: string,
|
||||
occurence: string[],
|
||||
startAt: string,
|
||||
timezone: string,
|
||||
maxOccurrences = 10,
|
||||
): Date[] | null {
|
||||
try {
|
||||
if (!repeatEvery || !occurence.length || !startAt || !timezone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
|
||||
let occurrences: Date[] = [];
|
||||
|
||||
if (repeatEvery === 'month') {
|
||||
const targetDays = occurence
|
||||
.map((day) => parseInt(day, 10))
|
||||
.filter((day) => !Number.isNaN(day));
|
||||
occurrences = generateMonthlyOccurrences(
|
||||
targetDays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
timezone,
|
||||
maxOccurrences,
|
||||
);
|
||||
} else if (repeatEvery === 'week') {
|
||||
const targetWeekdays = occurence
|
||||
.map((day) => WEEKDAY_MAP[day.toLowerCase()])
|
||||
.filter((day) => day !== undefined);
|
||||
occurrences = generateWeeklyOccurrences(
|
||||
targetWeekdays,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
timezone,
|
||||
maxOccurrences,
|
||||
);
|
||||
}
|
||||
|
||||
occurrences.sort((a, b) => a.getTime() - b.getTime());
|
||||
return occurrences.slice(0, maxOccurrences);
|
||||
} catch (error) {
|
||||
console.error('Error building custom schedule:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
label: `${timezone.name} (${timezone.offset})`,
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
export function isValidRRule(rruleString: string): boolean {
|
||||
try {
|
||||
// normalize escaped \n
|
||||
const finalRRuleString = rruleString.replace(/\\n/g, '\n');
|
||||
rrulestr(finalRRuleString); // will throw if invalid
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
@@ -70,6 +74,48 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
||||
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
||||
};
|
||||
|
||||
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 0,
|
||||
timeUnit: '',
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: 0,
|
||||
},
|
||||
delayEvaluation: {
|
||||
delay: 0,
|
||||
timeUnit: '',
|
||||
},
|
||||
evaluationCadence: {
|
||||
mode: 'default',
|
||||
default: {
|
||||
value: 1,
|
||||
timeUnit: 'm',
|
||||
},
|
||||
custom: {
|
||||
repeatEvery: 'week',
|
||||
startAt: '00:00:00',
|
||||
occurence: [],
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
rrule: {
|
||||
date: dayjs(),
|
||||
startAt: '00:00:00',
|
||||
rrule: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
|
||||
windowType: 'rolling',
|
||||
timeframe: '5m0s',
|
||||
startingAt: {
|
||||
time: '00:00:00',
|
||||
number: '1',
|
||||
timezone: TIMEZONE_DATA[0].value,
|
||||
},
|
||||
};
|
||||
|
||||
export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
@@ -115,3 +161,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
|
||||
{ value: Seasonality.DAILY, label: 'Daily' },
|
||||
{ value: Seasonality.WEEKLY, label: 'Weekly' },
|
||||
];
|
||||
|
||||
export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
||||
{ value: 's', label: 'Second' },
|
||||
{ value: 'm', label: 'Minute' },
|
||||
{ value: 'h', label: 'Hours' },
|
||||
{ value: 'd', label: 'Day' },
|
||||
];
|
||||
|
||||
@@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
alertThresholdReducer,
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
} from './utils';
|
||||
|
||||
@@ -80,6 +84,16 @@ export function CreateAlertProvider(
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
);
|
||||
|
||||
const [evaluationWindow, setEvaluationWindow] = useReducer(
|
||||
evaluationWindowReducer,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
);
|
||||
|
||||
const [advancedOptions, setAdvancedOptions] = useReducer(
|
||||
advancedOptionsReducer,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
@@ -94,8 +108,19 @@ export function CreateAlertProvider(
|
||||
setAlertType: handleAlertTypeChange,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
}),
|
||||
[alertState, alertType, handleAlertTypeChange, thresholdState],
|
||||
[
|
||||
alertState,
|
||||
alertType,
|
||||
handleAlertTypeChange,
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch } from 'react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
@@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
|
||||
setAlertType: Dispatch<AlertTypes>;
|
||||
thresholdState: AlertThresholdState;
|
||||
setThresholdState: Dispatch<AlertThresholdAction>;
|
||||
advancedOptions: AdvancedOptionsState;
|
||||
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
@@ -101,3 +106,86 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface AdvancedOptionsState {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: number;
|
||||
};
|
||||
delayEvaluation: {
|
||||
delay: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
evaluationCadence: {
|
||||
mode: EvaluationCadenceMode;
|
||||
default: {
|
||||
value: number;
|
||||
timeUnit: string;
|
||||
};
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
occurence: string[];
|
||||
timezone: string;
|
||||
};
|
||||
rrule: {
|
||||
date: Dayjs | null;
|
||||
startAt: string;
|
||||
rrule: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type AdvancedOptionsAction =
|
||||
| {
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: { toleranceLimit: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: { minimumDatapoints: number };
|
||||
}
|
||||
| {
|
||||
type: 'SET_DELAY_EVALUATION';
|
||||
payload: { delay: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'SET_EVALUATION_CADENCE';
|
||||
payload: {
|
||||
default: { value: number; timeUnit: string };
|
||||
custom: {
|
||||
repeatEvery: string;
|
||||
startAt: string;
|
||||
timezone: string;
|
||||
occurence: string[];
|
||||
};
|
||||
rrule: { date: Dayjs | null; startAt: string; rrule: string };
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface EvaluationWindowState {
|
||||
windowType: 'rolling' | 'cumulative';
|
||||
timeframe: string;
|
||||
startingAt: {
|
||||
time: string;
|
||||
number: string;
|
||||
timezone: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type EvaluationWindowAction =
|
||||
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
|
||||
| { type: 'SET_TIMEFRAME'; payload: string }
|
||||
| {
|
||||
type: 'SET_STARTING_AT';
|
||||
payload: { time: string; number: string; timezone: string };
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
|
||||
@@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdState,
|
||||
CreateAlertAction,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
} from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
@@ -110,3 +118,57 @@ export const alertThresholdReducer = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const advancedOptionsReducer = (
|
||||
state: AdvancedOptionsState,
|
||||
action: AdvancedOptionsAction,
|
||||
): AdvancedOptionsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
|
||||
return { ...state, sendNotificationIfDataIsMissing: action.payload };
|
||||
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
|
||||
return { ...state, enforceMinimumDatapoints: action.payload };
|
||||
case 'SET_DELAY_EVALUATION':
|
||||
return { ...state, delayEvaluation: action.payload };
|
||||
case 'SET_EVALUATION_CADENCE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
|
||||
};
|
||||
case 'SET_EVALUATION_CADENCE_MODE':
|
||||
return {
|
||||
...state,
|
||||
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ADVANCED_OPTIONS_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluationWindowReducer = (
|
||||
state: EvaluationWindowState,
|
||||
action: EvaluationWindowAction,
|
||||
): EvaluationWindowState => {
|
||||
switch (action.type) {
|
||||
case 'SET_WINDOW_TYPE':
|
||||
return {
|
||||
...state,
|
||||
windowType: action.payload,
|
||||
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
timeframe:
|
||||
action.payload === 'rolling'
|
||||
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
|
||||
: 'currentHour',
|
||||
};
|
||||
case 'SET_TIMEFRAME':
|
||||
return { ...state, timeframe: action.payload };
|
||||
case 'SET_STARTING_AT':
|
||||
return { ...state, startingAt: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16122,6 +16122,13 @@ robust-predicates@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
||||
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
||||
|
||||
rrule@2.8.1:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e"
|
||||
integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user