Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion keep-ui/app/(keep)/alerts/[id]/ui/alerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { AlertDismissModal } from "@/features/alerts/dismiss-alert";
import { ViewAlertModal } from "@/features/alerts/view-raw-alert";
import { AlertChangeStatusModal } from "@/features/alerts/alert-change-status";
import { EnrichAlertSidePanel } from "@/features/alerts/enrich-alert";
import { AlertSidebar } from "@/features/alerts/alert-detail-sidebar";
import { AlertAssociateIncidentModal } from "@/features/alerts/alert-associate-to-incident";
import { FacetDto } from "@/features/filter";
import { useApi } from "@/shared/lib/hooks/useApi";
import { KeepLoader, showErrorToast } from "@/shared/ui";
Expand Down Expand Up @@ -75,6 +77,10 @@ export default function Alerts({ presetName, initialFacets }: AlertsProps) {
const [viewEnrichAlertModal, setEnrichAlertModal] =
useState<AlertDto | null>();
const [isEnrichSidebarOpen, setIsEnrichSidebarOpen] = useState(false);
// hooks for alert sidebar
const [sidebarAlert, setSidebarAlert] = useState<AlertDto | null>();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isIncidentSelectorOpen, setIsIncidentSelectorOpen] = useState(false);
const { dynamicPresets: savedPresets = [], isLoading: _isPresetsLoading } =
usePresets({
revalidateOnFocus: false,
Expand All @@ -99,6 +105,8 @@ export default function Alerts({ presetName, initialFacets }: AlertsProps) {
useEffect(() => {
const fingerprint = searchParams?.get("alertPayloadFingerprint");
const enrich = searchParams?.get("enrich");
const sidebarFingerprint = searchParams?.get("sidebarFingerprint");

if (fingerprint && enrich && alerts) {
const alert = alerts?.find((alert) => alert.fingerprint === fingerprint);
if (alert) {
Expand All @@ -121,6 +129,21 @@ export default function Alerts({ presetName, initialFacets }: AlertsProps) {
setEnrichAlertModal(null);
setIsEnrichSidebarOpen(false);
}

// Handle sidebar opening/closing based on URL parameter
if (sidebarFingerprint && alerts) {
const alert = alerts?.find((alert) => alert.fingerprint === sidebarFingerprint);
if (alert) {
setSidebarAlert(alert);
setIsSidebarOpen(true);
} else {
showErrorToast(null, "Alert fingerprint not found");
closeSidebar();
}
} else if (alerts) {
setSidebarAlert(null);
setIsSidebarOpen(false);
}
}, [searchParams, alerts]);

const alertsQueryStateRef = useRef(alertsQueryState);
Expand All @@ -146,7 +169,7 @@ export default function Alerts({ presetName, initialFacets }: AlertsProps) {
const resetUrlAfterModal = useCallback(() => {
const currentParams = new URLSearchParams(window.location.search);
Array.from(currentParams.keys())
.filter((paramKey) => paramKey !== "cel")
.filter((paramKey) => paramKey !== "cel" && paramKey !== "sidebarFingerprint") // Keep sidebar parameter
.forEach((paramKey) => currentParams.delete(paramKey));
let url = `${window.location.pathname}`;

Expand All @@ -157,6 +180,18 @@ export default function Alerts({ presetName, initialFacets }: AlertsProps) {
router.replace(url);
}, [router]);

const closeSidebar = useCallback(() => {
const currentParams = new URLSearchParams(window.location.search);
currentParams.delete("sidebarFingerprint"); // Only remove sidebar parameter
let url = `${window.location.pathname}`;

if (currentParams.toString()) {
url += `?${currentParams.toString()}`;
}

router.replace(url);
}, [router]);

// if we don't have presets data yet, just show loading
if (!selectedPreset && isPresetsLoading) {
return <KeepLoader />;
Expand Down Expand Up @@ -237,6 +272,24 @@ export default function Alerts({ presetName, initialFacets }: AlertsProps) {
}}
mutate={mutateAlerts}
/>
<AlertSidebar
isOpen={isSidebarOpen}
toggle={closeSidebar}
alert={sidebarAlert ?? null}
setRunWorkflowModalAlert={setRunWorkflowModalAlert}
setDismissModalAlert={setDismissModalAlert}
setChangeStatusAlert={setChangeStatusAlert}
setIsIncidentSelectorOpen={setIsIncidentSelectorOpen}
/>
<AlertAssociateIncidentModal
isOpen={isIncidentSelectorOpen}
alerts={sidebarAlert ? [sidebarAlert] : []}
handleSuccess={() => {
setIsIncidentSelectorOpen(false);
mutateAlerts();
}}
handleClose={() => setIsIncidentSelectorOpen(false)}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,31 @@ import { Severity as IncidentSeverity } from '@/entities/incidents/model/models'
import type { AlertDto } from '@/entities/alerts/model/types';
import { Status as AlertStatus, Severity as AlertSeverity } from '@/entities/alerts/model/types';

let mockSearchParamsState: Record<string, string | null> = {};

// Mock all external dependencies
jest.mock('next/navigation', () => ({
useRouter: jest.fn(() => ({ push: jest.fn() })),
useRouter: jest.fn(() => ({
push: jest.fn(),
replace: jest.fn((url: string) => {
try {
const urlObj = new URL(url, 'http://localhost');
const params = new URLSearchParams(urlObj.search);
mockSearchParamsState = {
sidebarFingerprint: params.get('sidebarFingerprint'),
};
} catch (e) {
const searchPart = url.split('?')[1] || '';
const params = new URLSearchParams(searchPart);
mockSearchParamsState = {
sidebarFingerprint: params.get('sidebarFingerprint'),
};
}
}),
})),
useSearchParams: jest.fn(() => ({
get: jest.fn((key: string) => mockSearchParamsState[key] || null),
})),
}));

jest.mock('@/utils/hooks/useIncidents', () => ({
Expand Down Expand Up @@ -281,6 +303,8 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
alert: null,
};

mockSearchParamsState = {};

// Mock successful data fetching
useIncidentAlerts.mockReturnValue({
data: mockIncidentAlerts,
Expand All @@ -303,12 +327,17 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
});

it('should open AlertSidebar when clicking on alert row', async () => {
render(<IncidentAlerts incident={mockIncident} />);
const { rerender } = render(<IncidentAlerts incident={mockIncident} />);
expect(screen.queryByTestId('alert-sidebar')).not.toBeInTheDocument();

// Click on the first alert row
const alertRow = screen.getByTestId('alert-row-alert-1');
fireEvent.click(alertRow);

mockSearchParamsState.sidebarFingerprint = 'alert-1';

rerender(<IncidentAlerts incident={mockIncident} />);

// Verify AlertSidebar is opened with correct alert
await waitFor(() => {
expect(screen.getByTestId('alert-sidebar')).toBeInTheDocument();
Expand All @@ -323,13 +352,17 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
});

it('should open AlertSidebar when clicking view details button', async () => {
render(<IncidentAlerts incident={mockIncident} />);
const { rerender } = render(<IncidentAlerts incident={mockIncident} />);

// Note: The view button actually opens ViewAlertModal, not AlertSidebar
// Let's click directly on the row to test AlertSidebar
const alertRow = screen.getByTestId('alert-row-alert-2');
fireEvent.click(alertRow);

mockSearchParamsState.sidebarFingerprint = 'alert-2';

rerender(<IncidentAlerts incident={mockIncident} />);

// Verify AlertSidebar is opened with correct alert
await waitFor(() => {
expect(screen.getByTestId('alert-sidebar')).toBeInTheDocument();
Expand All @@ -344,11 +377,9 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
});

it('should close AlertSidebar when clicking close button', async () => {
render(<IncidentAlerts incident={mockIncident} />);

// Open the sidebar first
const alertRow = screen.getByTestId('alert-row-alert-1');
fireEvent.click(alertRow);
mockSearchParamsState.sidebarFingerprint = 'alert-1';

const { rerender } = render(<IncidentAlerts incident={mockIncident} />);

// Verify sidebar is open
await waitFor(() => {
Expand All @@ -359,6 +390,10 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
const closeButton = screen.getByTestId('close-sidebar');
fireEvent.click(closeButton);

mockSearchParamsState.sidebarFingerprint = null;

rerender(<IncidentAlerts incident={mockIncident} />);

// Verify sidebar is closed
await waitFor(() => {
expect(screen.queryByTestId('alert-sidebar')).not.toBeInTheDocument();
Expand All @@ -369,11 +404,9 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
});

it('should close AlertSidebar when clicking outside without errors', async () => {
render(<IncidentAlerts incident={mockIncident} />);

// Open the sidebar first
const alertRow = screen.getByTestId('alert-row-alert-1');
fireEvent.click(alertRow);
mockSearchParamsState.sidebarFingerprint = 'alert-1';

const { rerender } = render(<IncidentAlerts incident={mockIncident} />);

// Verify sidebar is open
await waitFor(() => {
Expand All @@ -384,6 +417,10 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
const closeButton = screen.getByTestId('close-sidebar');
fireEvent.click(closeButton);

mockSearchParamsState.sidebarFingerprint = null;

rerender(<IncidentAlerts incident={mockIncident} />);

// Verify sidebar closes without errors
await waitFor(() => {
expect(screen.queryByTestId('alert-sidebar')).not.toBeInTheDocument();
Expand All @@ -398,19 +435,23 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
});

it('should switch between different alerts in sidebar', async () => {
render(<IncidentAlerts incident={mockIncident} />);

// Open sidebar for first alert
fireEvent.click(screen.getByTestId('alert-row-alert-1'));
mockSearchParamsState.sidebarFingerprint = 'alert-1';

const { rerender } = render(<IncidentAlerts incident={mockIncident} />);

await waitFor(() => {
const sidebarContent = screen.getByTestId('alert-sidebar-content');
expect(sidebarContent).toHaveTextContent('Test Alert 1');
});
expect(alertSidebarState.alert?.name).toBe('Test Alert 1');

// Click on second alert row to switch
fireEvent.click(screen.getByTestId('alert-row-alert-2'));
// Switch to second alert by clicking on its row
const alertRow2 = screen.getByTestId('alert-row-alert-2');
fireEvent.click(alertRow2);

mockSearchParamsState.sidebarFingerprint = 'alert-2';

rerender(<IncidentAlerts incident={mockIncident} />);

await waitFor(() => {
const sidebarContent = screen.getByTestId('alert-sidebar-content');
Expand Down Expand Up @@ -477,7 +518,7 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
mutate: jest.fn(),
});

render(<IncidentAlerts incident={mockIncident} />);
const { rerender } = render(<IncidentAlerts incident={mockIncident} />);

// First, open ViewAlertModal with view button
const viewButtons = screen.getAllByLabelText('View Alert Details');
Expand All @@ -492,6 +533,10 @@ describe('IncidentAlerts - AlertSidebar Integration', () => {
const firstAlertRow = alertRows[0];
fireEvent.click(firstAlertRow);

mockSearchParamsState.sidebarFingerprint = 'alert-1';

rerender(<IncidentAlerts incident={mockIncident} />);

// Both should be open now
await waitFor(() => {
expect(screen.getByTestId('view-alert-modal')).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import IncidentAlerts from '../incident-alerts';
import type {
IncidentDto,
Expand All @@ -24,6 +24,7 @@ import { useConfig } from '@/utils/hooks/useConfig';
// Mock the dependencies
jest.mock('next/navigation', () => ({
useRouter: jest.fn(),
useSearchParams: jest.fn(),
}));

jest.mock('@/utils/hooks/useIncidents', () => ({
Expand Down Expand Up @@ -218,6 +219,11 @@ describe('IncidentAlerts', () => {
// Setup default mock returns
(useRouter as jest.Mock).mockReturnValue({
push: jest.fn(),
replace: jest.fn(),
});

(useSearchParams as jest.Mock).mockReturnValue({
get: jest.fn(() => null),
});

(useIncidentAlerts as jest.Mock).mockReturnValue({
Expand Down Expand Up @@ -261,6 +267,12 @@ describe('IncidentAlerts', () => {
// - Row clicks open AlertSidebar

it('opens AlertSidebar when clicking on alert row', async () => {
const mockReplace = jest.fn();
(useRouter as jest.Mock).mockReturnValue({
push: jest.fn(),
replace: mockReplace,
});

render(<IncidentAlerts incident={mockIncident} />);

// Click on the first alert row
Expand All @@ -269,10 +281,11 @@ describe('IncidentAlerts', () => {
fireEvent.click(alertRow);
}

// Check if AlertSidebar is opened
// Verify that the URL was updated with the sidebarFingerprint parameter
await waitFor(() => {
expect(screen.getByTestId('alert-sidebar')).toBeInTheDocument();
expect(screen.getByTestId('alert-sidebar-content')).toHaveTextContent('Test Alert 1');
expect(mockReplace).toHaveBeenCalled();
const callArg = mockReplace.mock.calls[0][0];
expect(callArg).toContain('sidebarFingerprint=alert-1');
});
});

Expand Down
Loading
Loading