Skip to content

Commit b3531e6

Browse files
[Alerting V2] Episode table actions (elastic#260195)
Closes elastic#258318 Closes elastic#258319 ## Summary Adds logic to the alert episodes table to display `.alert_actions` information. This includes: - New action-specific API paths. - Snooze - **Per group hash.** - Button in the actions column opens a popover where an `until` can be picked. - **When snoozed** - A bell shows up in the status column. - Mouse over the bell icon to see until when the snooze is in effect. - Unsnooze - **Per group hash.** - Clicking the button removes the snooze. - Ack/Unack - **Per episode.** - Button in the actions column - When "acked", an icon shows in the status column. - Tags - This PR only handles displaying tags. They need to be created via API. - Resolve/Unresolve - **Per group hash.** - Button inside the ellipsis always - The status is turned to `inactive` **regardless of the "real" status.** <img width="1704" height="672" alt="Screenshot 2026-03-25 at 16 04 12" src="https://github.com/user-attachments/assets/5ef4111a-6e0c-4114-a60e-ce5f81a86ac6" /> ## Testing <details> <summary>POST mock episodes</summary> ``` POST _bulk { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-001", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:01:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-001", "status": "pending" }, "status": "no_data" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:02:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-001", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:03:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-001", "status": "inactive" }, "status": "no_data" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:04:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-001", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-001", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:06:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-001", "status": "active" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:07:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-2", "episode": { "id": "ep-002", "status": "active" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:08:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-2", "episode": { "id": "ep-002", "status": "active" }, "status": "no_data" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:09:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-2", "episode": { "id": "ep-002", "status": "recovering" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-2", "episode": { "id": "ep-002", "status": "recovering" }, "status": "no_data" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:11:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-2", "episode": { "id": "ep-002", "status": "active" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:12:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-2", "episode": { "id": "ep-002", "status": "recovering" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:13:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-2", "episode": { "id": "ep-002", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:14:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-003", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "gh-1", "episode": { "id": "ep-003", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:16:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-4", "episode": { "id": "ep-004", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:17:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-4", "episode": { "id": "ep-004", "status": "active" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:18:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-4", "episode": { "id": "ep-004", "status": "recovering" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:19:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-4", "episode": { "id": "ep-004", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-5", "episode": { "id": "ep-005", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:21:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-5", "episode": { "id": "ep-005", "status": "pending" }, "status": "no_data" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:22:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-5", "episode": { "id": "ep-005", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:23:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-9", "episode": { "id": "ep-006", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:24:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-9", "episode": { "id": "ep-006", "status": "active" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:25:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-9", "episode": { "id": "ep-006", "status": "active" }, "status": "no_data" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:26:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-1" }, "group_hash": "elasticgh-9", "episode": { "id": "ep-006", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:14:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-2" }, "group_hash": "elasticgh-7", "episode": { "id": "ep-007", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-2" }, "group_hash": "elasticgh-7", "episode": { "id": "ep-007", "status": "inactive" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:16:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-3" }, "group_hash": "elasticgh-8", "episode": { "id": "ep-008", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:17:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-3" }, "group_hash": "elasticgh-8", "episode": { "id": "ep-008", "status": "active" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:18:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-3" }, "group_hash": "elasticgh-8", "episode": { "id": "ep-008", "status": "recovering" }, "status": "recovered" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-4" }, "group_hash": "elasticgh-9", "episode": { "id": "ep-009", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:21:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-4" }, "group_hash": "elasticgh-9", "episode": { "id": "ep-009", "status": "pending" }, "status": "no_data" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:23:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-5" }, "group_hash": "elasticgh-10", "episode": { "id": "ep-010", "status": "pending" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:24:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-5" }, "group_hash": "elasticgh-10", "episode": { "id": "ep-010", "status": "active" }, "status": "breached" } { "create": { "_index": ".rule-events" }} { "@timestamp": "2026-01-27T16:25:00.000Z", "source": "internal", "type": "alert", "rule": { "id": "rule-5" }, "group_hash": "elasticgh-10", "episode": { "id": "ep-010", "status": "active" }, "status": "no_data" } ``` </details> - In the POST above, episodes 1 and 3, and episodes 6 and 9 have the same group hashes. - Go to `https://localhost:5601/app/observability/alerts-v2` and try all buttons. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 4afde99 commit b3531e6

66 files changed

Lines changed: 3008 additions & 813 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen } from '@testing-library/react';
10+
import userEvent from '@testing-library/user-event';
11+
import type { HttpStart } from '@kbn/core-http-browser';
12+
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
13+
import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas';
14+
import { AcknowledgeActionButton } from './acknowledge_action_button';
15+
import { useCreateAlertAction } from '../../../hooks/use_create_alert_action';
16+
17+
jest.mock('../../../hooks/use_create_alert_action');
18+
19+
const useCreateAlertActionMock = jest.mocked(useCreateAlertAction);
20+
21+
const mockHttp: HttpStart = httpServiceMock.createStartContract();
22+
23+
describe('AcknowledgeActionButton', () => {
24+
const mutate = jest.fn();
25+
beforeEach(() => {
26+
mutate.mockReset();
27+
useCreateAlertActionMock.mockReturnValue({
28+
mutate,
29+
isLoading: false,
30+
} as unknown as ReturnType<typeof useCreateAlertAction>);
31+
});
32+
33+
it('renders Acknowledge when lastAckAction is undefined (same as not acknowledged)', () => {
34+
render(<AcknowledgeActionButton http={mockHttp} />);
35+
expect(screen.getByText('Acknowledge')).toBeInTheDocument();
36+
});
37+
38+
it('renders Unacknowledge when lastAckAction is ack', () => {
39+
render(
40+
<AcknowledgeActionButton lastAckAction={ALERT_EPISODE_ACTION_TYPE.ACK} http={mockHttp} />
41+
);
42+
expect(screen.getByText('Unacknowledge')).toBeInTheDocument();
43+
});
44+
45+
it('renders Acknowledge when lastAckAction is unack', () => {
46+
render(
47+
<AcknowledgeActionButton lastAckAction={ALERT_EPISODE_ACTION_TYPE.UNACK} http={mockHttp} />
48+
);
49+
expect(screen.getByText('Acknowledge')).toBeInTheDocument();
50+
});
51+
52+
it('calls ack route mutation on click', async () => {
53+
const user = userEvent.setup();
54+
render(
55+
<AcknowledgeActionButton
56+
lastAckAction={ALERT_EPISODE_ACTION_TYPE.UNACK}
57+
episodeId="ep-1"
58+
groupHash="gh-1"
59+
http={mockHttp}
60+
/>
61+
);
62+
63+
await user.click(screen.getByTestId('alertEpisodeAcknowledgeActionButton'));
64+
65+
expect(mutate).toHaveBeenCalledWith({
66+
groupHash: 'gh-1',
67+
actionType: ALERT_EPISODE_ACTION_TYPE.ACK,
68+
body: { episode_id: 'ep-1' },
69+
});
70+
});
71+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useCallback } from 'react';
9+
import { EuiButton } from '@elastic/eui';
10+
import { i18n } from '@kbn/i18n';
11+
import type { HttpStart } from '@kbn/core-http-browser';
12+
import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas';
13+
import { useCreateAlertAction } from '../../../hooks/use_create_alert_action';
14+
15+
export interface AcknowledgeActionButtonProps {
16+
lastAckAction?: string | null;
17+
episodeId?: string;
18+
groupHash?: string | null;
19+
http: HttpStart;
20+
}
21+
22+
export function AcknowledgeActionButton({
23+
lastAckAction,
24+
episodeId,
25+
groupHash,
26+
http,
27+
}: AcknowledgeActionButtonProps) {
28+
const isAcknowledged = lastAckAction === ALERT_EPISODE_ACTION_TYPE.ACK;
29+
const actionType = isAcknowledged
30+
? ALERT_EPISODE_ACTION_TYPE.UNACK
31+
: ALERT_EPISODE_ACTION_TYPE.ACK;
32+
const { mutate: createAlertAction, isLoading } = useCreateAlertAction(http);
33+
34+
const label = isAcknowledged
35+
? i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.unacknowledge', {
36+
defaultMessage: 'Unacknowledge',
37+
})
38+
: i18n.translate('xpack.alertingV2.episodesUi.acknowledgeAction.acknowledge', {
39+
defaultMessage: 'Acknowledge',
40+
});
41+
42+
const handleClick = useCallback(() => {
43+
if (!episodeId || !groupHash) {
44+
return;
45+
}
46+
createAlertAction({
47+
groupHash,
48+
actionType,
49+
body: { episode_id: episodeId },
50+
});
51+
}, [createAlertAction, episodeId, groupHash, actionType]);
52+
53+
return (
54+
<EuiButton
55+
size="s"
56+
color="text"
57+
fill={false}
58+
iconType={isAcknowledged ? 'crossCircle' : 'checkCircle'}
59+
onClick={handleClick}
60+
isLoading={isLoading}
61+
isDisabled={!episodeId || !groupHash}
62+
data-test-subj="alertEpisodeAcknowledgeActionButton"
63+
>
64+
{label}
65+
</EuiButton>
66+
);
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen } from '@testing-library/react';
10+
import userEvent from '@testing-library/user-event';
11+
import type { HttpStart } from '@kbn/core-http-browser';
12+
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
13+
import { ALERT_EPISODE_ACTION_TYPE } from '@kbn/alerting-v2-schemas';
14+
import { AlertEpisodeActionsCell } from './alert_episode_actions_cell';
15+
import { useCreateAlertAction } from '../../../hooks/use_create_alert_action';
16+
17+
jest.mock('../../../hooks/use_create_alert_action');
18+
19+
const useCreateAlertActionMock = jest.mocked(useCreateAlertAction);
20+
21+
const mockHttp: HttpStart = httpServiceMock.createStartContract();
22+
23+
describe('AlertEpisodeActionsCell', () => {
24+
beforeEach(() => {
25+
useCreateAlertActionMock.mockReturnValue({
26+
mutate: jest.fn(),
27+
isLoading: false,
28+
} as unknown as ReturnType<typeof useCreateAlertAction>);
29+
});
30+
31+
it('renders acknowledge, snooze, and more-actions controls', () => {
32+
render(<AlertEpisodeActionsCell http={mockHttp} />);
33+
expect(screen.getByTestId('alertEpisodeAcknowledgeActionButton')).toBeInTheDocument();
34+
expect(screen.getByTestId('alertEpisodeSnoozeActionButton')).toBeInTheDocument();
35+
expect(screen.getByTestId('alertingEpisodeActionsMoreButton')).toBeInTheDocument();
36+
});
37+
38+
it('opens popover and shows Resolve when not deactivated', async () => {
39+
const user = userEvent.setup();
40+
render(
41+
<AlertEpisodeActionsCell
42+
http={mockHttp}
43+
groupAction={{
44+
groupHash: 'g1',
45+
ruleId: 'r1',
46+
lastDeactivateAction: null,
47+
lastSnoozeAction: null,
48+
snoozeExpiry: null,
49+
tags: [],
50+
}}
51+
/>
52+
);
53+
await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton'));
54+
expect(screen.getByText('Resolve')).toBeInTheDocument();
55+
});
56+
57+
it('shows Unresolve in popover when group action is deactivated', async () => {
58+
const user = userEvent.setup();
59+
render(
60+
<AlertEpisodeActionsCell
61+
http={mockHttp}
62+
groupAction={{
63+
groupHash: 'g1',
64+
ruleId: 'r1',
65+
lastDeactivateAction: ALERT_EPISODE_ACTION_TYPE.DEACTIVATE,
66+
lastSnoozeAction: null,
67+
snoozeExpiry: null,
68+
tags: [],
69+
}}
70+
/>
71+
);
72+
await user.click(screen.getByTestId('alertingEpisodeActionsMoreButton'));
73+
expect(screen.getByText('Unresolve')).toBeInTheDocument();
74+
});
75+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useState } from 'react';
9+
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiListGroup, EuiPopover } from '@elastic/eui';
10+
import { i18n } from '@kbn/i18n';
11+
import type { HttpStart } from '@kbn/core-http-browser';
12+
import { AcknowledgeActionButton } from './acknowledge_action_button';
13+
import { SnoozeActionButton } from './snooze_action_button';
14+
import type { EpisodeAction, GroupAction } from '../../../types/action';
15+
import { ResolveActionButton } from './resolve_action_button';
16+
17+
export interface AlertEpisodeActionsCellProps {
18+
episodeId?: string;
19+
groupHash?: string;
20+
episodeAction?: EpisodeAction;
21+
groupAction?: GroupAction;
22+
http: HttpStart;
23+
}
24+
25+
export function AlertEpisodeActionsCell({
26+
episodeId,
27+
groupHash,
28+
episodeAction,
29+
groupAction,
30+
http,
31+
}: AlertEpisodeActionsCellProps) {
32+
const [isMoreOpen, setIsMoreOpen] = useState(false);
33+
34+
return (
35+
<EuiFlexGroup
36+
gutterSize="xs"
37+
wrap
38+
responsive={true}
39+
alignItems="center"
40+
justifyContent="flexEnd"
41+
>
42+
<EuiFlexItem grow={false}>
43+
<AcknowledgeActionButton
44+
lastAckAction={episodeAction?.lastAckAction}
45+
episodeId={episodeId}
46+
groupHash={groupHash}
47+
http={http}
48+
/>
49+
</EuiFlexItem>
50+
<EuiFlexItem grow={false}>
51+
<SnoozeActionButton
52+
lastSnoozeAction={groupAction?.lastSnoozeAction}
53+
groupHash={groupHash}
54+
http={http}
55+
/>
56+
</EuiFlexItem>
57+
<EuiFlexItem grow={false}>
58+
<EuiPopover
59+
aria-label={i18n.translate(
60+
'xpack.alertingV2.episodesUi.actionsCell.moreActionsAriaLabel',
61+
{
62+
defaultMessage: 'More actions',
63+
}
64+
)}
65+
button={
66+
<EuiButtonIcon
67+
display="empty"
68+
color="text"
69+
size="xs"
70+
iconType="boxesHorizontal"
71+
aria-label={i18n.translate(
72+
'xpack.alertingV2.episodesUi.actionsCell.moreActionsAriaLabel',
73+
{
74+
defaultMessage: 'More actions',
75+
}
76+
)}
77+
onClick={() => setIsMoreOpen((open) => !open)}
78+
data-test-subj="alertingEpisodeActionsMoreButton"
79+
/>
80+
}
81+
isOpen={isMoreOpen}
82+
closePopover={() => setIsMoreOpen(false)}
83+
anchorPosition="downLeft"
84+
panelPaddingSize="s"
85+
>
86+
<EuiListGroup gutterSize="none" bordered={false} flush={true} size="l">
87+
<ResolveActionButton
88+
lastDeactivateAction={groupAction?.lastDeactivateAction}
89+
groupHash={groupHash}
90+
http={http}
91+
/>
92+
</EuiListGroup>
93+
</EuiPopover>
94+
</EuiFlexItem>
95+
</EuiFlexGroup>
96+
);
97+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import userEvent from '@testing-library/user-event';
10+
import { render, screen } from '@testing-library/react';
11+
import { AlertEpisodeSnoozeForm, computeEpisodeSnoozedUntil } from './alert_episode_snooze_form';
12+
13+
describe('AlertEpisodeSnoozeForm', () => {
14+
it('computeEpisodeSnoozedUntil returns a future ISO date', () => {
15+
const before = Date.now();
16+
const result = computeEpisodeSnoozedUntil(1, 'h');
17+
const after = Date.now();
18+
const parsed = Date.parse(result);
19+
20+
expect(Number.isNaN(parsed)).toBe(false);
21+
expect(parsed).toBeGreaterThanOrEqual(before + 3_600_000);
22+
expect(parsed).toBeLessThanOrEqual(after + 3_600_000 + 1_000);
23+
});
24+
25+
it('applies preset snooze duration when a preset is clicked', async () => {
26+
const user = userEvent.setup();
27+
const onApplySnooze = jest.fn();
28+
29+
render(<AlertEpisodeSnoozeForm onApplySnooze={onApplySnooze} />);
30+
31+
await user.click(screen.getByRole('button', { name: '1 hour' }));
32+
33+
expect(onApplySnooze).toHaveBeenCalledTimes(1);
34+
expect(Number.isNaN(Date.parse(onApplySnooze.mock.calls[0][0]))).toBe(false);
35+
});
36+
});

0 commit comments

Comments
 (0)