mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 23:47:12 +00:00
feat: base setup for new create alerts page (#8957)
This commit is contained in:
parent
6709b09646
commit
717efaf167
@ -0,0 +1,47 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import LabelsInput from './LabelsInput';
|
||||
|
||||
function CreateAlertHeader(): JSX.Element {
|
||||
const { alertState, setAlertState } = useCreateAlertState();
|
||||
|
||||
return (
|
||||
<div className="alert-header">
|
||||
<div className="alert-header__tab-bar">
|
||||
<div className="alert-header__tab">New Alert Rule</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-header__content">
|
||||
<input
|
||||
type="text"
|
||||
value={alertState.name}
|
||||
onChange={(e): void =>
|
||||
setAlertState({ type: 'SET_ALERT_NAME', payload: e.target.value })
|
||||
}
|
||||
className="alert-header__input title"
|
||||
placeholder="Enter alert rule name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={alertState.description}
|
||||
onChange={(e): void =>
|
||||
setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value })
|
||||
}
|
||||
className="alert-header__input description"
|
||||
placeholder="Click to add description..."
|
||||
/>
|
||||
<LabelsInput
|
||||
labels={alertState.labels}
|
||||
onLabelsChange={(labels: Labels): void =>
|
||||
setAlertState({ type: 'SET_ALERT_LABELS', payload: labels })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAlertHeader;
|
||||
@ -0,0 +1,153 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { LabelInputState, LabelsInputProps } from './types';
|
||||
|
||||
function LabelsInput({
|
||||
labels,
|
||||
onLabelsChange,
|
||||
}: LabelsInputProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const [inputState, setInputState] = useState<LabelInputState>({
|
||||
key: '',
|
||||
value: '',
|
||||
isKeyInput: true,
|
||||
});
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleAddLabelsClick = useCallback(() => {
|
||||
setIsAdding(true);
|
||||
setInputState({ key: '', value: '', isKeyInput: true });
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (inputState.isKeyInput) {
|
||||
// Check if input contains a colon (key:value format)
|
||||
if (inputState.key.includes(':')) {
|
||||
const [key, ...valueParts] = inputState.key.split(':');
|
||||
const value = valueParts.join(':'); // Rejoin in case value contains colons
|
||||
|
||||
if (key.trim() && value.trim()) {
|
||||
if (labels[key.trim()]) {
|
||||
notifications.error({
|
||||
message: 'Label with this key already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Add the label immediately
|
||||
const newLabels = {
|
||||
...labels,
|
||||
[key.trim()]: value.trim(),
|
||||
};
|
||||
onLabelsChange(newLabels);
|
||||
|
||||
// Reset input state
|
||||
setInputState({ key: '', value: '', isKeyInput: true });
|
||||
}
|
||||
} else if (inputState.key.trim()) {
|
||||
if (labels[inputState.key.trim()]) {
|
||||
notifications.error({
|
||||
message: 'Label with this key already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setInputState((prev) => ({ ...prev, isKeyInput: false }));
|
||||
}
|
||||
} else if (inputState.value.trim()) {
|
||||
// Add the label
|
||||
const newLabels = {
|
||||
...labels,
|
||||
[inputState.key.trim()]: inputState.value.trim(),
|
||||
};
|
||||
onLabelsChange(newLabels);
|
||||
|
||||
// Reset and continue adding
|
||||
setInputState({ key: '', value: '', isKeyInput: true });
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// Cancel adding
|
||||
setIsAdding(false);
|
||||
setInputState({ key: '', value: '', isKeyInput: true });
|
||||
}
|
||||
},
|
||||
[inputState, labels, notifications, onLabelsChange],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (inputState.isKeyInput) {
|
||||
setInputState((prev) => ({ ...prev, key: e.target.value }));
|
||||
} else {
|
||||
setInputState((prev) => ({ ...prev, value: e.target.value }));
|
||||
}
|
||||
},
|
||||
[inputState.isKeyInput],
|
||||
);
|
||||
|
||||
const handleRemoveLabel = useCallback(
|
||||
(key: string) => {
|
||||
const newLabels = { ...labels };
|
||||
delete newLabels[key];
|
||||
onLabelsChange(newLabels);
|
||||
},
|
||||
[labels, onLabelsChange],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (!inputState.key && !inputState.value) {
|
||||
setIsAdding(false);
|
||||
setInputState({ key: '', value: '', isKeyInput: true });
|
||||
}
|
||||
}, [inputState]);
|
||||
|
||||
return (
|
||||
<div className="labels-input">
|
||||
{Object.keys(labels).length > 0 && (
|
||||
<div className="labels-input__existing-labels">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<span key={key} className="labels-input__label-pill">
|
||||
{key}: {value}
|
||||
<button
|
||||
type="button"
|
||||
className="labels-input__remove-button"
|
||||
onClick={(): void => handleRemoveLabel(key)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdding ? (
|
||||
<button
|
||||
className="labels-input__add-button"
|
||||
type="button"
|
||||
onClick={handleAddLabelsClick}
|
||||
>
|
||||
+ Add labels
|
||||
</button>
|
||||
) : (
|
||||
<div className="labels-input__input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={inputState.isKeyInput ? inputState.key : inputState.value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className="labels-input__input"
|
||||
placeholder={inputState.isKeyInput ? 'Enter key' : 'Enter value'}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelsInput;
|
||||
@ -0,0 +1,56 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import CreateAlertHeader from '../CreateAlertHeader';
|
||||
|
||||
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>,
|
||||
);
|
||||
|
||||
describe('CreateAlertHeader', () => {
|
||||
it('renders the header with title', () => {
|
||||
renderCreateAlertHeader();
|
||||
expect(screen.getByText('New Alert Rule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders name input with placeholder', () => {
|
||||
renderCreateAlertHeader();
|
||||
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description input with placeholder', () => {
|
||||
renderCreateAlertHeader();
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
'Click to add description...',
|
||||
);
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders LabelsInput component', () => {
|
||||
renderCreateAlertHeader();
|
||||
expect(screen.getByText('+ Add labels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates name when typing in name input', () => {
|
||||
renderCreateAlertHeader();
|
||||
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Alert' } });
|
||||
|
||||
expect(nameInput).toHaveValue('Test Alert');
|
||||
});
|
||||
|
||||
it('updates description when typing in description input', () => {
|
||||
renderCreateAlertHeader();
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
'Click to add description...',
|
||||
);
|
||||
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
|
||||
expect(descriptionInput).toHaveValue('Test Description');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,503 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import LabelsInput from '../LabelsInput';
|
||||
import { LabelsInputProps } from '../types';
|
||||
|
||||
// Mock the CloseOutlined icon
|
||||
jest.mock('@ant-design/icons', () => ({
|
||||
CloseOutlined: (): JSX.Element => <span data-testid="close-icon">×</span>,
|
||||
}));
|
||||
|
||||
const mockOnLabelsChange = jest.fn();
|
||||
|
||||
const defaultProps: LabelsInputProps = {
|
||||
labels: {},
|
||||
onLabelsChange: mockOnLabelsChange,
|
||||
};
|
||||
|
||||
const ADD_LABELS_TEXT = '+ Add labels';
|
||||
const ENTER_KEY_PLACEHOLDER = 'Enter key';
|
||||
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
|
||||
|
||||
const CLOSE_ICON_TEST_ID = 'close-icon';
|
||||
const SEVERITY_HIGH_TEXT = 'severity: high';
|
||||
const ENVIRONMENT_PRODUCTION_TEXT = 'environment: production';
|
||||
const SEVERITY_HIGH_KEY_VALUE = 'severity:high';
|
||||
|
||||
const renderLabelsInput = (
|
||||
props: Partial<LabelsInputProps> = {},
|
||||
): ReturnType<typeof render> =>
|
||||
render(<LabelsInput {...defaultProps} {...props} />);
|
||||
|
||||
describe('LabelsInput', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initial Rendering', () => {
|
||||
it('renders add button when no labels exist', () => {
|
||||
renderLabelsInput();
|
||||
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(CLOSE_ICON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing labels when provided', () => {
|
||||
const labels = { severity: 'high', environment: 'production' };
|
||||
renderLabelsInput({ labels });
|
||||
|
||||
expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not render existing labels section when no labels', () => {
|
||||
renderLabelsInput();
|
||||
expect(screen.queryByText(SEVERITY_HIGH_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adding Labels', () => {
|
||||
it('shows input field when add button is clicked', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches from key input to value input on Enter', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds label when both key and value are provided', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// Enter value
|
||||
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
|
||||
fireEvent.change(valueInput, { target: { value: 'high' } });
|
||||
fireEvent.keyDown(valueInput, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
|
||||
});
|
||||
|
||||
it('does not switch to value input if key is empty', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add label if value is empty', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// Try to add with empty value
|
||||
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
|
||||
fireEvent.keyDown(valueInput, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trims whitespace from key and value', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key with whitespace
|
||||
fireEvent.change(input, { target: { value: ' severity ' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// Enter value with whitespace
|
||||
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
|
||||
fireEvent.change(valueInput, { target: { value: ' high ' } });
|
||||
fireEvent.keyDown(valueInput, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
|
||||
});
|
||||
|
||||
it('resets input state after adding label', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Add a label
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
|
||||
fireEvent.change(valueInput, { target: { value: 'high' } });
|
||||
fireEvent.keyDown(valueInput, { key: 'Enter' });
|
||||
|
||||
// Should be back to key input
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Removing Labels', () => {
|
||||
it('removes label when close button is clicked', () => {
|
||||
const labels = { severity: 'high', environment: 'production' };
|
||||
renderLabelsInput({ labels });
|
||||
|
||||
const removeButtons = screen.getAllByTestId(CLOSE_ICON_TEST_ID);
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledWith({
|
||||
environment: 'production',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onLabelsChange with empty object when last label is removed', () => {
|
||||
const labels = { severity: 'high' };
|
||||
renderLabelsInput({ labels });
|
||||
|
||||
const removeButton = screen.getByTestId('close-icon');
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Interactions', () => {
|
||||
it('cancels adding label on Escape key', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels adding label on Escape key in value input', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// Cancel in value input
|
||||
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
|
||||
fireEvent.keyDown(valueInput, { key: 'Escape' });
|
||||
|
||||
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blur Behavior', () => {
|
||||
it('closes input immediately when both key and value are empty', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
fireEvent.blur(input);
|
||||
|
||||
// The input should close immediately when both key and value are empty
|
||||
expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not close input immediately when key has value', () => {
|
||||
jest.useFakeTimers();
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Change Handling', () => {
|
||||
it('updates key input value correctly', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
|
||||
expect(input).toHaveValue('severity');
|
||||
});
|
||||
|
||||
it('updates value input correctly', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// Update value
|
||||
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
|
||||
fireEvent.change(valueInput, { target: { value: 'high' } });
|
||||
|
||||
expect(valueInput).toHaveValue('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles multiple labels correctly', () => {
|
||||
const labels = {
|
||||
severity: 'high',
|
||||
environment: 'production',
|
||||
service: 'api-gateway',
|
||||
};
|
||||
renderLabelsInput({ labels });
|
||||
|
||||
expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText('service: api-gateway')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles empty string values', () => {
|
||||
const labels = { severity: '' };
|
||||
renderLabelsInput({ labels });
|
||||
|
||||
expect(screen.getByText(/severity/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles special characters in labels', () => {
|
||||
const labels = { 'service-name': 'api-gateway-v1' };
|
||||
renderLabelsInput({ labels });
|
||||
|
||||
expect(screen.getByText('service-name: api-gateway-v1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains focus on input after adding label', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Add a label
|
||||
fireEvent.change(input, { target: { value: 'severity' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER);
|
||||
fireEvent.change(valueInput, { target: { value: 'high' } });
|
||||
fireEvent.keyDown(valueInput, { key: 'Enter' });
|
||||
|
||||
// Should be focused on new key input
|
||||
const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
expect(newInput).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key:Value Format Support', () => {
|
||||
it('adds label when key:value format is entered and Enter is pressed', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key:value format
|
||||
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
|
||||
});
|
||||
|
||||
it('trims whitespace from key and value in key:value format', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key:value format with whitespace
|
||||
fireEvent.change(input, { target: { value: ' severity : high ' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
|
||||
});
|
||||
|
||||
it('handles values with colons correctly', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key:value format where value contains colons
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'url:https://example.com:8080' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledWith({
|
||||
url: 'https://example.com:8080',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add label if key is empty in key:value format', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key:value format with empty key
|
||||
fireEvent.change(input, { target: { value: ':high' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not add label if value is empty in key:value format', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter key:value format with empty value
|
||||
fireEvent.change(input, { target: { value: 'severity:' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not add label if only colon is entered', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Enter only colon
|
||||
fireEvent.change(input, { target: { value: ':' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnLabelsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets input state after adding label with key:value format', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Add label with key:value format
|
||||
fireEvent.change(input, { target: { value: 'severity:high' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// Should be back to key input for next label
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not auto-save when typing key:value without pressing Enter', () => {
|
||||
renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Type key:value format but don't press Enter
|
||||
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
|
||||
|
||||
// Should not have called onLabelsChange yet
|
||||
expect(mockOnLabelsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles multiple key:value entries correctly', () => {
|
||||
const { rerender } = renderLabelsInput();
|
||||
|
||||
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
|
||||
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
|
||||
// Add first label
|
||||
fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
// Simulate parent component updating labels
|
||||
const firstLabels = { severity: 'high' };
|
||||
rerender(
|
||||
<LabelsInput labels={firstLabels} onLabelsChange={mockOnLabelsChange} />,
|
||||
);
|
||||
|
||||
// Add second label
|
||||
const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
|
||||
fireEvent.change(newInput, { target: { value: 'environment:production' } });
|
||||
fireEvent.keyDown(newInput, { key: 'Enter' });
|
||||
|
||||
// Check that we made two calls and the last one includes both labels
|
||||
expect(mockOnLabelsChange).toHaveBeenCalledTimes(2);
|
||||
expect(mockOnLabelsChange).toHaveBeenNthCalledWith(1, { severity: 'high' });
|
||||
expect(mockOnLabelsChange).toHaveBeenNthCalledWith(2, {
|
||||
severity: 'high',
|
||||
environment: 'production',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import CreateAlertHeader from './CreateAlertHeader';
|
||||
|
||||
export default CreateAlertHeader;
|
||||
@ -0,0 +1,151 @@
|
||||
.alert-header {
|
||||
background-color: var(--bg-ink-500);
|
||||
font-family: inherit;
|
||||
color: var(--text-vanilla-100);
|
||||
|
||||
/* Top bar with diagonal stripes */
|
||||
&__tab-bar {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#0f0f0f,
|
||||
#0f0f0f 10px,
|
||||
#101010 10px,
|
||||
#101010 20px
|
||||
);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Tab block visuals */
|
||||
&__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-ink-500);
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
color: var(--text-vanilla-100);
|
||||
margin-left: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__tab::before {
|
||||
content: '•';
|
||||
margin-right: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--bg-slate-100);
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 16px;
|
||||
background: var(--bg-ink-500);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__input.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
background-color: transparent;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
&__input:focus,
|
||||
&__input:active {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__input.description {
|
||||
font-size: 14px;
|
||||
background-color: transparent;
|
||||
color: var(--text-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.labels-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&__add-button {
|
||||
width: fit-content;
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
border: 1px solid #333;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__existing-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: #ad7f581a;
|
||||
color: var(--bg-sienna-400);
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--bg-sienna-500);
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
&__remove-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--bg-sienna-400);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 6px 8px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
export interface LabelsInputProps {
|
||||
labels: Labels;
|
||||
onLabelsChange: (labels: Labels) => void;
|
||||
}
|
||||
|
||||
export interface LabelInputState {
|
||||
key: string;
|
||||
value: string;
|
||||
isKeyInput: boolean;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-ink-500);
|
||||
}
|
||||
16
frontend/src/container/CreateAlertV2/CreateAlertV2.tsx
Normal file
16
frontend/src/container/CreateAlertV2/CreateAlertV2.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import './CreateAlertV2.styles.scss';
|
||||
|
||||
import { CreateAlertProvider } from './context';
|
||||
import CreateAlertHeader from './CreateAlertHeader/CreateAlertHeader';
|
||||
|
||||
function CreateAlertV2(): JSX.Element {
|
||||
return (
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAlertV2;
|
||||
18
frontend/src/container/CreateAlertV2/Stepper/index.tsx
Normal file
18
frontend/src/container/CreateAlertV2/Stepper/index.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import './styles.scss';
|
||||
|
||||
interface StepperProps {
|
||||
stepNumber: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function Stepper({ stepNumber, label }: StepperProps): JSX.Element {
|
||||
return (
|
||||
<div className="stepper-container">
|
||||
<div className="step-number">{stepNumber}</div>
|
||||
<div className="step-label">{label}</div>
|
||||
<div className="dotted-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Stepper;
|
||||
44
frontend/src/container/CreateAlertV2/Stepper/styles.scss
Normal file
44
frontend/src/container/CreateAlertV2/Stepper/styles.scss
Normal file
@ -0,0 +1,44 @@
|
||||
.stepper-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-robin-400);
|
||||
color: var(--text-slate-400);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: #e5e7eb;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotted-line {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-image: radial-gradient(circle, #4a4a4a 1px, transparent 1px);
|
||||
background-size: 8px 8px;
|
||||
background-repeat: repeat-x;
|
||||
background-position: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { AlertState } from './types';
|
||||
|
||||
export const INITIAL_ALERT_STATE: AlertState = {
|
||||
name: '',
|
||||
description: '',
|
||||
labels: {},
|
||||
};
|
||||
58
frontend/src/container/CreateAlertV2/context/index.tsx
Normal file
58
frontend/src/container/CreateAlertV2/context/index.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { INITIAL_ALERT_STATE } from './constants';
|
||||
import {
|
||||
AlertCreationStep,
|
||||
ICreateAlertContextProps,
|
||||
ICreateAlertProviderProps,
|
||||
} from './types';
|
||||
import { alertCreationReducer } from './utils';
|
||||
|
||||
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
|
||||
|
||||
// Hook exposing context state for CreateAlert
|
||||
export const useCreateAlertState = (): ICreateAlertContextProps => {
|
||||
const context = useContext(CreateAlertContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useCreateAlertState must be used within CreateAlertProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export function CreateAlertProvider(
|
||||
props: ICreateAlertProviderProps,
|
||||
): JSX.Element {
|
||||
const { children } = props;
|
||||
|
||||
const [alertState, setAlertState] = useReducer(
|
||||
alertCreationReducer,
|
||||
INITIAL_ALERT_STATE,
|
||||
);
|
||||
const [step, setStep] = useState<AlertCreationStep>(
|
||||
AlertCreationStep.ALERT_DEFINITION,
|
||||
);
|
||||
|
||||
const contextValue: ICreateAlertContextProps = useMemo(
|
||||
() => ({
|
||||
alertState,
|
||||
setAlertState,
|
||||
step,
|
||||
setStep,
|
||||
}),
|
||||
[alertState, setAlertState, step, setStep],
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateAlertContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</CreateAlertContext.Provider>
|
||||
);
|
||||
}
|
||||
31
frontend/src/container/CreateAlertV2/context/types.ts
Normal file
31
frontend/src/container/CreateAlertV2/context/types.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Dispatch } from 'react';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
export interface ICreateAlertContextProps {
|
||||
alertState: AlertState;
|
||||
setAlertState: Dispatch<CreateAlertAction>;
|
||||
step: AlertCreationStep;
|
||||
setStep: Dispatch<AlertCreationStep>;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export enum AlertCreationStep {
|
||||
ALERT_DEFINITION = 0,
|
||||
ALERT_CONDITION = 1,
|
||||
EVALUATION_SETTINGS = 2,
|
||||
NOTIFICATION_SETTINGS = 3,
|
||||
}
|
||||
|
||||
export interface AlertState {
|
||||
name: string;
|
||||
description: string;
|
||||
labels: Labels;
|
||||
}
|
||||
|
||||
export type CreateAlertAction =
|
||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ALERT_LABELS'; payload: Labels };
|
||||
26
frontend/src/container/CreateAlertV2/context/utils.tsx
Normal file
26
frontend/src/container/CreateAlertV2/context/utils.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { AlertState, CreateAlertAction } from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
state: AlertState,
|
||||
action: CreateAlertAction,
|
||||
): AlertState => {
|
||||
switch (action.type) {
|
||||
case 'SET_ALERT_NAME':
|
||||
return {
|
||||
...state,
|
||||
name: action.payload,
|
||||
};
|
||||
case 'SET_ALERT_DESCRIPTION':
|
||||
return {
|
||||
...state,
|
||||
description: action.payload,
|
||||
};
|
||||
case 'SET_ALERT_LABELS':
|
||||
return {
|
||||
...state,
|
||||
labels: action.payload,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
3
frontend/src/container/CreateAlertV2/index.ts
Normal file
3
frontend/src/container/CreateAlertV2/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import CreateAlertV2 from './CreateAlertV2';
|
||||
|
||||
export default CreateAlertV2;
|
||||
3
frontend/src/container/CreateAlertV2/utils.tsx
Normal file
3
frontend/src/container/CreateAlertV2/utils.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
// UI side feature flag
|
||||
export const showNewCreateAlertsPage = (): boolean =>
|
||||
localStorage.getItem('showNewCreateAlertsPage') === 'true';
|
||||
@ -1,6 +1,14 @@
|
||||
import CreateAlertRule from 'container/CreateAlertRule';
|
||||
import CreateAlertV2 from 'container/CreateAlertV2';
|
||||
import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
|
||||
|
||||
function CreateAlertPage(): JSX.Element {
|
||||
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
|
||||
|
||||
if (showNewCreateAlertsPageFlag) {
|
||||
return <CreateAlertV2 />;
|
||||
}
|
||||
|
||||
return <CreateAlertRule />;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user