feat: base setup for new create alerts page (#8957)

This commit is contained in:
Amlan Kumar Nandy 2025-09-09 14:56:29 +07:00 committed by GitHub
parent 6709b09646
commit 717efaf167
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1142 additions and 0 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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',
});
});
});
});

View File

@ -0,0 +1,3 @@
import CreateAlertHeader from './CreateAlertHeader';
export default CreateAlertHeader;

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
.create-alert-v2-container {
background-color: var(--bg-ink-500);
}

View 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;

View 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;

View 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;
}

View File

@ -0,0 +1,7 @@
import { AlertState } from './types';
export const INITIAL_ALERT_STATE: AlertState = {
name: '',
description: '',
labels: {},
};

View 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>
);
}

View 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 };

View 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;
}
};

View File

@ -0,0 +1,3 @@
import CreateAlertV2 from './CreateAlertV2';
export default CreateAlertV2;

View File

@ -0,0 +1,3 @@
// UI side feature flag
export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true';

View File

@ -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 />;
}