Skip to content

Commit e8eb4c6

Browse files
[Cases] [Observability] Disable add-to-case functionality when all selected alerts are already attached (#231877)
## Summary Resolves #227036. For o11y alerting, we want to make it so that if a user selects a set of cases, and the full set of selected alerts is already on the case, the add to case button will be disabled on the add-to-existing-case modal. ~This functionality is transparent to other consumers of this feature~ This functionality is driven by the `getAttachments` function implemented by the caller of the _Add to existing case_ modal. The expectation is that `getAttachments` will provide a list of all the selected attachments. This code then queries all of the existing user comments of type `alert` attached to the cases appearing in the modal. The code diffs the selected alerts against the existing attachments. If all the selected alerts intersect with the existing case attachments, that case's button will be disabled and include copy and an icon to indicate that it already contains the selected alerts. **Caveat:** This feature as it is written only works for cases with 100 or fewer attached alerts. In the case where a case has > 100 alerts attached to it, the functionality will work as it does today and the user can click the Add button. The code already filters any existing cases further down the procedure, and I am assuming most cases have fewer than 100 alerts attached in today's common usage, so this tradeoff seemed preferable to adding the complexity and querying load of pagination logic. <img width="1165" height="391" alt="image" src="https://github.com/user-attachments/assets/bf0b5daa-4b3c-452b-889b-ccd4a2f9906d" /> --------- Co-authored-by: kibanamachine <[email protected]>
1 parent 5473f20 commit e8eb4c6

File tree

17 files changed

+338
-12
lines changed

17 files changed

+338
-12
lines changed

x-pack/platform/plugins/shared/cases/common/constants/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ export const INTERNAL_CASE_FIND_USER_ACTIONS_URL =
9696
`${CASES_INTERNAL_URL}/{case_id}/user_actions/_find` as const;
9797
export const INTERNAL_CASE_SUMMARY_URL = `${CASES_INTERNAL_URL}/{case_id}/summary` as const;
9898
export const INTERNAL_INFERENCE_CONNECTORS_URL = '/internal/inference/connectors' as const;
99+
export const INTERNAL_CASE_GET_CASES_BY_ATTACHMENT_URL =
100+
`${CASES_INTERNAL_URL}/case/alerts/_find_containing_all` as const;
99101

100102
/**
101103
* Action routes

x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,23 @@ export const GetRelatedCasesByAlertResponseRt = rt.array(RelatedCaseRt);
532532

533533
export const SimilarCasesSearchRequestRt = paginationSchema({ maxPerPage: MAX_CASES_PER_PAGE });
534534

535+
export const FindCasesContainingAllAlertsRequestRt = rt.exact(
536+
rt.type({
537+
/**
538+
* The IDs of the alerts to find cases for.
539+
*/
540+
alertIds: rt.array(rt.string),
541+
// The IDs of the cases to find alerts for.
542+
caseIds: rt.array(rt.string),
543+
})
544+
);
545+
546+
export const FindCasesContainingAllAlertsResponseRt = rt.exact(
547+
rt.type({
548+
casesWithAllAttachments: rt.array(rt.string),
549+
})
550+
);
551+
535552
export type CasePostRequest = rt.TypeOf<typeof CasePostRequestRt>;
536553
export type CaseResolveResponse = rt.TypeOf<typeof CaseResolveResponseRt>;
537554
export type CasesDeleteRequest = rt.TypeOf<typeof CasesDeleteRequestRt>;
@@ -557,3 +574,9 @@ export type BulkCreateCasesRequest = rt.TypeOf<typeof BulkCreateCasesRequestRt>;
557574
export type BulkCreateCasesResponse = rt.TypeOf<typeof BulkCreateCasesResponseRt>;
558575
export type SimilarCasesSearchRequest = rt.TypeOf<typeof SimilarCasesSearchRequestRt>;
559576
export type CasesSimilarResponse = rt.TypeOf<typeof CasesSimilarResponseRt>;
577+
export type FindCasesContainingAllAlertsRequest = rt.TypeOf<
578+
typeof FindCasesContainingAllAlertsRequestRt
579+
>;
580+
export type FindCasesContainingAllAlertsResponse = rt.TypeOf<
581+
typeof FindCasesContainingAllAlertsResponseRt
582+
>;

x-pack/platform/plugins/shared/cases/public/client/ui/get_all_cases_selector_modal.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@ export const getAllCasesSelectorModalNoProviderLazy = ({
5959
hiddenStatuses,
6060
onRowClick,
6161
onClose,
62+
getAttachments,
6263
}: AllCasesSelectorModalProps) => (
6364
<Suspense fallback={<EuiLoadingSpinner />}>
6465
<AllCasesSelectorModalLazy
6566
hiddenStatuses={hiddenStatuses}
6667
onRowClick={onRowClick}
6768
onClose={onClose}
69+
getAttachments={getAttachments}
6870
/>
6971
</Suspense>
7072
);

x-pack/platform/plugins/shared/cases/public/components/all_cases/all_cases_list.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { useAvailableCasesOwners } from '../app/use_available_owners';
3333
import { useCasesColumnsSelection } from './use_cases_columns_selection';
3434
import { DEFAULT_CASES_TABLE_STATE } from '../../containers/constants';
3535
import { CasesTableUtilityBar } from './utility_bar';
36+
import { useCheckAlertAttachments } from '../../containers/use_check_alert_attachments';
37+
import { type GetAttachments } from './selector_modal/use_cases_add_to_existing_case_modal';
3638

3739
const getSortField = (field: string): SortFieldCase =>
3840
// @ts-ignore
@@ -42,10 +44,11 @@ export interface AllCasesListProps {
4244
hiddenStatuses?: CaseStatuses[];
4345
isSelectorView?: boolean;
4446
onRowClick?: (theCase?: CaseUI, isCreateCase?: boolean) => void;
47+
getAttachments?: GetAttachments;
4548
}
4649

4750
export const AllCasesList = React.memo<AllCasesListProps>(
48-
({ hiddenStatuses = [], isSelectorView = false, onRowClick }) => {
51+
({ hiddenStatuses = [], isSelectorView = false, onRowClick, getAttachments }) => {
4952
const { owner, permissions } = useCasesContext();
5053

5154
const availableSolutions = useAvailableCasesOwners(getAllPermissionsExceptFrom('delete'));
@@ -64,6 +67,11 @@ export const AllCasesList = React.memo<AllCasesListProps>(
6467
queryParams,
6568
});
6669

70+
const { disabledCases, isLoading: isLoadingCaseAttachments } = useCheckAlertAttachments({
71+
cases: data.cases,
72+
getAttachments,
73+
});
74+
6775
const assigneesFromCases = useMemo(() => {
6876
return data.cases.reduce<Set<string>>((acc, caseInfo) => {
6977
if (!caseInfo) {
@@ -141,6 +149,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
141149
onRowClick,
142150
disableActions: selectedCases.length > 0,
143151
selectedColumns,
152+
disabledCases,
144153
});
145154

146155
const pagination = useMemo(
@@ -233,7 +242,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
233242
data={data}
234243
goToCreateCase={onRowClick ? onCreateCasePressed : undefined}
235244
isCasesLoading={isLoadingCases}
236-
isLoadingColumns={isLoadingColumns}
245+
isLoadingColumns={isLoadingColumns || isLoadingCaseAttachments}
237246
isCommentUpdating={isLoadingCases}
238247
isDataEmpty={isDataEmpty}
239248
isSelectorView={isSelectorView}

x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,18 @@ import type { CaseStatuses } from '../../../../common/types/domain';
2121
import type { CaseUI } from '../../../../common/ui/types';
2222
import * as i18n from '../../../common/translations';
2323
import { AllCasesList } from '../all_cases_list';
24+
import { type GetAttachments } from './use_cases_add_to_existing_case_modal';
2425

2526
export interface AllCasesSelectorModalProps {
2627
hiddenStatuses?: CaseStatuses[];
2728
onRowClick?: (theCase?: CaseUI) => void;
2829
onClose?: (theCase?: CaseUI, isCreateCase?: boolean) => void;
2930
onCreateCaseClicked?: () => void;
31+
getAttachments?: GetAttachments;
3032
}
3133

3234
export const AllCasesSelectorModal = React.memo<AllCasesSelectorModalProps>(
33-
({ hiddenStatuses, onRowClick, onClose }) => {
35+
({ hiddenStatuses, onRowClick, onClose, getAttachments }) => {
3436
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
3537
const { euiTheme } = useEuiTheme();
3638
const closeModal = useCallback(() => {
@@ -70,6 +72,7 @@ export const AllCasesSelectorModal = React.memo<AllCasesSelectorModalProps>(
7072
hiddenStatuses={hiddenStatuses}
7173
isSelectorView={true}
7274
onRowClick={onClick}
75+
getAttachments={getAttachments}
7376
/>
7477
</EuiModalBody>
7578
<EuiModalFooter>

x-pack/platform/plugins/shared/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export type AddToExistingCaseModalProps = Omit<AllCasesSelectorModalProps, 'onRo
3131
onSuccess?: (theCase: CaseUI) => void;
3232
};
3333

34+
export type GetAttachments = ({ theCase }: { theCase?: CaseUI }) => CaseAttachmentsWithoutOwner;
35+
3436
export const useCasesAddToExistingCaseModal = ({
3537
successToaster,
3638
noAttachmentsToaster,
@@ -135,13 +137,14 @@ export const useCasesAddToExistingCaseModal = ({
135137
({
136138
getAttachments,
137139
}: {
138-
getAttachments?: ({ theCase }: { theCase?: CaseUI }) => CaseAttachmentsWithoutOwner;
140+
getAttachments?: GetAttachments;
139141
} = {}) => {
140142
dispatch({
141143
type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL,
142144
payload: {
143145
hiddenStatuses: [CaseStatuses.closed],
144146
onCreateCaseClicked,
147+
getAttachments,
145148
onRowClick: (theCase?: CaseUI) => {
146149
handleOnRowClick(theCase, getAttachments);
147150
},

x-pack/platform/plugins/shared/cases/public/components/all_cases/translations.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ export const SELECT = i18n.translate('xpack.cases.caseTable.select', {
9696
defaultMessage: 'Select',
9797
});
9898

99+
export const ALREADY_ATTACHED = i18n.translate('xpack.cases.caseTable.alreadyAttached', {
100+
defaultMessage: 'Added',
101+
description:
102+
'In this context, "Added" is letting the user know that all of their selected alerts were previously added to the case in question, and the "Add to case" button is disabled',
103+
});
104+
99105
export const REQUIRES_UPDATE = i18n.translate('xpack.cases.caseTable.requiresUpdate', {
100106
defaultMessage: ' requires update',
101107
});

x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export interface GetCasesColumn {
6666
connectors?: ActionConnector[];
6767
onRowClick?: (theCase: CaseUI) => void;
6868
disableActions?: boolean;
69+
disabledCases?: Set<string>;
6970
}
7071

7172
export interface UseCasesColumnsReturnValue {
@@ -81,6 +82,7 @@ export const useCasesColumns = ({
8182
onRowClick,
8283
disableActions = false,
8384
selectedColumns,
85+
disabledCases,
8486
}: GetCasesColumn): UseCasesColumnsReturnValue => {
8587
const casesColumnsConfig = useCasesColumnsConfiguration(isSelectorView);
8688
const { actions } = useActions({ disableActions });
@@ -311,15 +313,16 @@ export const useCasesColumns = ({
311313
align: RIGHT_ALIGNMENT,
312314
render: (theCase: CaseUI) => {
313315
if (theCase.id != null) {
316+
const disabled = disabledCases?.has(theCase.id) ?? false;
314317
return (
315318
<EuiButton
316319
data-test-subj={`cases-table-row-select-${theCase.id}`}
317-
onClick={() => {
318-
assignCaseAction(theCase);
319-
}}
320+
onClick={() => assignCaseAction(theCase)}
320321
size="s"
322+
iconType={disabled ? 'check' : undefined}
323+
disabled={disabled}
321324
>
322-
{i18n.SELECT}
325+
{disabled ? i18n.ALREADY_ATTACHED : i18n.SELECT}
323326
</EuiButton>
324327
);
325328
}
@@ -328,7 +331,7 @@ export const useCasesColumns = ({
328331
width: '120px',
329332
},
330333
}),
331-
[assignCaseAction, casesColumnsConfig, connectors, isSelectorView, userProfiles]
334+
[assignCaseAction, casesColumnsConfig, connectors, isSelectorView, userProfiles, disabledCases]
332335
);
333336

334337
// we need to extend the columnsDict with the columns of

x-pack/platform/plugins/shared/cases/public/containers/api.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
UserActionInternalFindResponse,
2828
CaseSummaryResponse,
2929
InferenceConnectorsResponse,
30+
FindCasesContainingAllAlertsResponse,
3031
} from '../../common/types/api';
3132
import type {
3233
CaseConnectors,
@@ -72,6 +73,7 @@ import {
7273
INTERNAL_BULK_CREATE_ATTACHMENTS_URL,
7374
INTERNAL_GET_CASE_CATEGORIES_URL,
7475
CASES_INTERNAL_URL,
76+
INTERNAL_CASE_GET_CASES_BY_ATTACHMENT_URL,
7577
} from '../../common/constants';
7678
import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api';
7779

@@ -111,6 +113,7 @@ import {
111113
constructCustomFieldsFilter,
112114
decodeCaseSummaryResponse,
113115
decodeInferenceConnectorsResponse,
116+
decodeFindAllAttachedAlertsResponse,
114117
} from './utils';
115118
import { decodeCasesFindResponse, decodeCasesSimilarResponse } from '../api/decoders';
116119

@@ -221,6 +224,20 @@ export const getInferenceConnectors = async (
221224
return decodeInferenceConnectorsResponse(response);
222225
};
223226

227+
export const findCasesByAttachmentId = async (alertIds: string[], caseIds: string[]) => {
228+
const response = await KibanaServices.get().http.fetch<FindCasesContainingAllAlertsResponse>(
229+
`${INTERNAL_CASE_GET_CASES_BY_ATTACHMENT_URL}`,
230+
{
231+
method: 'POST',
232+
body: JSON.stringify({
233+
alertIds,
234+
caseIds,
235+
}),
236+
}
237+
);
238+
return decodeFindAllAttachedAlertsResponse(response);
239+
};
240+
224241
export const findCaseUserActions = async (
225242
caseId: string,
226243
params: {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 { useMemo } from 'react';
9+
import type { CaseUI } from './types';
10+
import { type GetAttachments } from '../components/all_cases/selector_modal/use_cases_add_to_existing_case_modal';
11+
import { useFindCasesContainingAllSelectedAlerts } from './use_find_cases_containing_all_selected_alerts';
12+
13+
export interface UseCheckAlertAttachmentsProps {
14+
cases: Pick<CaseUI, 'id'>[];
15+
getAttachments?: GetAttachments;
16+
}
17+
18+
function hasAlertId<T>(arg: T): arg is T & { alertId: string[] | string } {
19+
const candidate = arg as unknown;
20+
return (
21+
typeof candidate === 'object' &&
22+
candidate !== null &&
23+
'alertId' in candidate &&
24+
(Array.isArray(candidate.alertId) || typeof candidate.alertId === 'string')
25+
);
26+
}
27+
28+
export const useCheckAlertAttachments = ({
29+
cases,
30+
getAttachments,
31+
}: UseCheckAlertAttachmentsProps): { disabledCases: Set<string>; isLoading: boolean } => {
32+
const selectedAlerts = (getAttachments?.({ theCase: undefined }) ?? [])
33+
.filter(hasAlertId)
34+
.map(({ alertId }) => alertId)
35+
.flatMap((arrayOrString) => arrayOrString);
36+
37+
const { data, isFetching } = useFindCasesContainingAllSelectedAlerts(
38+
selectedAlerts,
39+
cases.map(({ id }) => id)
40+
);
41+
42+
const disabledCases = useMemo(() => new Set(data?.casesWithAllAttachments ?? []), [data]);
43+
44+
return { disabledCases, isLoading: isFetching };
45+
};
46+
47+
export type UseCheckAlertAttachments = ReturnType<typeof useCheckAlertAttachments>;

0 commit comments

Comments
 (0)