From f633c97890efac9c2970ce34183bd7ff6733e9e1 Mon Sep 17 00:00:00 2001 From: christineweng Date: Thu, 21 May 2026 17:22:49 -0500 Subject: [PATCH 1/8] case attachment tab ui --- .../translations/translations/de-DE.json | 3 - .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../case_view/case_view_page.test.tsx | 132 +---- .../components/case_view/case_view_page.tsx | 74 +-- .../components/case_view/case_view_tabs.tsx | 38 +- .../components/attachment_accordion.test.tsx | 51 ++ .../components/attachment_accordion.tsx | 66 +++ .../components/case_view_attachments.test.tsx | 467 ++++-------------- .../components/case_view_attachments.tsx | 169 +++---- .../components/case_view/translations.ts | 12 - .../use_case_attachment_tabs.test.tsx | 198 +++++--- .../case_view/use_case_attachment_tabs.tsx | 351 +++---------- .../apps/cases/group1/view_case.ts | 4 +- .../functional/test_suites/cases/view_case.ts | 3 +- .../test_suites/ftr/cases/view_case.ts | 3 +- 17 files changed, 513 insertions(+), 1067 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.test.tsx create mode 100644 x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 6da12aa29db75..8b8924a62066f 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -15943,10 +15943,7 @@ "xpack.cases.caseView.syncAlertsLabel": "Alerts synchronisieren", "xpack.cases.caseView.syncAlertsLowercaseLabel": "Alerts synchronisieren", "xpack.cases.caseView.tabs.activity": "Aktivität", - "xpack.cases.caseView.tabs.alerts": "Alerts", "xpack.cases.caseView.tabs.attachments": "Anhänge", - "xpack.cases.caseView.tabs.events": "Ereignisse", - "xpack.cases.caseView.tabs.files": "Dateien", "xpack.cases.caseView.tabs.observables": "Beobachtungsdaten", "xpack.cases.caseView.tabs.similar": "Ähnliche Tickets", "xpack.cases.caseView.tags": "Tags", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index cb0bed93e28b2..3ec5c3df86bc5 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -15917,10 +15917,7 @@ "xpack.cases.caseView.syncAlertsLabel": "Synchroniser les alertes", "xpack.cases.caseView.syncAlertsLowercaseLabel": "synchroniser les alertes", "xpack.cases.caseView.tabs.activity": "Activité", - "xpack.cases.caseView.tabs.alerts": "Alertes", "xpack.cases.caseView.tabs.attachments": "Pièces jointes", - "xpack.cases.caseView.tabs.events": "Événements", - "xpack.cases.caseView.tabs.files": "Fichiers", "xpack.cases.caseView.tabs.observables": "Observables", "xpack.cases.caseView.tabs.similar": "Cas similaires", "xpack.cases.caseView.tags": "Balises", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 3f4c91ccf748b..fcc4b2794b74c 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -15993,10 +15993,7 @@ "xpack.cases.caseView.syncAlertsLabel": "アラートの同期", "xpack.cases.caseView.syncAlertsLowercaseLabel": "アラートの同期", "xpack.cases.caseView.tabs.activity": "アクティビティ", - "xpack.cases.caseView.tabs.alerts": "アラート", "xpack.cases.caseView.tabs.attachments": "添付ファイル", - "xpack.cases.caseView.tabs.events": "イベント", - "xpack.cases.caseView.tabs.files": "ファイル", "xpack.cases.caseView.tabs.observables": "オブザーバブル", "xpack.cases.caseView.tabs.similar": "類似したケース", "xpack.cases.caseView.tags": "タグ", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 2c6a7cc4ad54d..05977fd9a090d 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -15994,10 +15994,7 @@ "xpack.cases.caseView.syncAlertsLabel": "同步告警", "xpack.cases.caseView.syncAlertsLowercaseLabel": "同步告警", "xpack.cases.caseView.tabs.activity": "活动", - "xpack.cases.caseView.tabs.alerts": "告警", "xpack.cases.caseView.tabs.attachments": "附件", - "xpack.cases.caseView.tabs.events": "事件", - "xpack.cases.caseView.tabs.files": "文件", "xpack.cases.caseView.tabs.observables": "可观察对象", "xpack.cases.caseView.tabs.similar": "类似案例", "xpack.cases.caseView.tags": "标签", diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.test.tsx index 0b2c9e13adecd..1ef80d5630b1d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.test.tsx @@ -15,7 +15,6 @@ import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { caseData, caseViewProps } from './mocks'; import type { CaseViewPageProps } from './types'; import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; -import { toUnifiedAttachmentType } from '../../../common/utils/attachments/migration_utils'; import { UnifiedAttachmentTypeRegistry } from '../../client/attachment_framework/unified_attachment_registry'; jest.mock('../../common/navigation/hooks'); @@ -31,14 +30,6 @@ jest.mock( }), { virtual: true } ); -jest.mock('../../../common/utils/attachments/migration_utils', () => { - const actual = jest.requireActual('../../../common/utils/attachments/migration_utils'); - - return { - ...actual, - toUnifiedAttachmentType: jest.fn(actual.toUnifiedAttachmentType), - }; -}); jest.mock('../header_page', () => ({ HeaderPage: jest.fn(() =>
{'Case view header'}
), @@ -56,9 +47,9 @@ jest.mock('./components/case_view_activity', () => ({ )), })); -jest.mock('./components/case_view_observables', () => ({ - CaseViewObservables: jest.fn(() => ( -
{'Case view observables'}
+jest.mock('./components/case_view_attachments', () => ({ + CaseViewAttachments: jest.fn(() => ( +
{'Case view attachments'}
)), })); @@ -70,9 +61,6 @@ jest.mock('./components/case_view_similar_cases', () => ({ const useUrlParamsMock = useUrlParams as jest.Mock; const useCasesTitleBreadcrumbsMock = useCasesTitleBreadcrumbs as jest.Mock; -const toUnifiedAttachmentTypeMock = toUnifiedAttachmentType as jest.MockedFunction< - typeof toUnifiedAttachmentType ->; const caseProps: CaseViewPageProps = { ...caseViewProps, @@ -87,40 +75,6 @@ describe('CaseViewPage', () => { jest.clearAllMocks(); useUrlParamsMock.mockReturnValue({}); unifiedAttachmentTypeRegistry = new UnifiedAttachmentTypeRegistry(); - unifiedAttachmentTypeRegistry.register({ - id: 'security.event', - displayName: 'Event', - icon: 'bell', - getAttachmentViewObject: () => ({ event: 'added an event' }), - getAttachmentTabViewObject: () => ({ - children: () => ( -
{'Events content'}
- ), - }), - schemaValidator: () => {}, - }); - unifiedAttachmentTypeRegistry.register({ - id: 'security.alert', - displayName: 'Alert', - icon: 'bell', - getAttachmentViewObject: () => ({ event: 'added an alert' }), - getAttachmentTabViewObject: () => ({ - children: () => ( -
{'Alerts content'}
- ), - }), - schemaValidator: () => {}, - }); - unifiedAttachmentTypeRegistry.register({ - id: 'file', - displayName: 'File', - icon: 'document', - getAttachmentViewObject: () => ({ event: 'added a file' }), - getAttachmentTabViewObject: () => ({ - children: () =>
{'Case view files'}
, - }), - schemaValidator: () => {}, - }); }); it('shows the header section', async () => { @@ -153,79 +107,19 @@ describe('CaseViewPage', () => { }); }); - it('resolves event type using the full case owner', () => { - const caseDataWithStringOwner = { ...caseProps.caseData, owner: 'securitySolution' }; - - renderWithTestingProviders(); - - expect(toUnifiedAttachmentTypeMock).toHaveBeenCalledWith('event', 'securitySolution'); - }); - - it('does not render the events tab content when events feature is disabled', () => { - useUrlParamsMock.mockReturnValue({ - urlParams: { tabId: CASE_VIEW_PAGE_TABS.EVENTS }, - }); - - renderWithTestingProviders(, { - wrapperProps: { - features: { events: { enabled: false } }, - unifiedAttachmentTypeRegistry, - }, - }); - - expect(screen.queryByTestId('test-case-view-events-content')).not.toBeInTheDocument(); - }); - - it('renders the events tab content when events feature is enabled and type is registered', async () => { - useUrlParamsMock.mockReturnValue({ - urlParams: { tabId: CASE_VIEW_PAGE_TABS.EVENTS }, - }); - - renderWithTestingProviders(, { - wrapperProps: { - features: { events: { enabled: true } }, - unifiedAttachmentTypeRegistry, - }, - }); - - expect(await screen.findByTestId('test-case-view-events-content')).toBeInTheDocument(); - }); - - it('resolves alert type using the full case owner', () => { - const caseDataWithStringOwner = { ...caseProps.caseData, owner: 'securitySolution' }; - - renderWithTestingProviders(); - - expect(toUnifiedAttachmentTypeMock).toHaveBeenCalledWith('alert', 'securitySolution'); - }); - - it('does not render the alerts tab content when alerts feature is disabled', () => { - useUrlParamsMock.mockReturnValue({ - urlParams: { tabId: CASE_VIEW_PAGE_TABS.ALERTS }, - }); - - renderWithTestingProviders(, { - wrapperProps: { - features: { alerts: { enabled: false } }, - unifiedAttachmentTypeRegistry, - }, - }); - - expect(screen.queryByTestId('test-case-view-alerts-content')).not.toBeInTheDocument(); - }); - - it('renders the alerts tab content when alerts feature is enabled and type is registered', async () => { - useUrlParamsMock.mockReturnValue({ - urlParams: { tabId: CASE_VIEW_PAGE_TABS.ALERTS }, - }); + it.each([ + CASE_VIEW_PAGE_TABS.ATTACHMENTS, + CASE_VIEW_PAGE_TABS.ALERTS, + CASE_VIEW_PAGE_TABS.EVENTS, + CASE_VIEW_PAGE_TABS.FILES, + CASE_VIEW_PAGE_TABS.OBSERVABLES, + ])('renders the consolidated attachments view for tabId=%s', async (tabId) => { + useUrlParamsMock.mockReturnValue({ urlParams: { tabId } }); renderWithTestingProviders(, { - wrapperProps: { - features: { alerts: { enabled: true } }, - unifiedAttachmentTypeRegistry, - }, + wrapperProps: { unifiedAttachmentTypeRegistry }, }); - expect(await screen.findByTestId('test-case-view-alerts-content')).toBeInTheDocument(); + expect(await screen.findByTestId('test-case-view-attachments')).toBeInTheDocument(); }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx index edbd36981d1ee..8052010a9b333 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx @@ -9,13 +9,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useUrlParams } from '../../common/navigation'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { CaseActionBar } from '../case_action_bar'; import { HeaderPage } from '../header_page'; import { EditableTitle } from '../header_page/editable_title'; import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; import { CaseViewActivity } from './components/case_view_activity'; -import { CaseViewObservables } from './components/case_view_observables'; import { CaseViewMetrics } from './metrics'; import type { CaseViewPageProps } from './types'; import { useRefreshCaseViewPage } from './use_on_refresh_case_view_page'; @@ -23,8 +21,7 @@ import { useOnUpdateField } from './use_on_update_field'; import { CaseViewSimilarCases } from './components/case_view_similar_cases'; import { CaseViewAttachments } from './components/case_view_attachments'; import { filterCaseAttachmentsBySearchTerm } from './components/helpers'; -import { toUnifiedAttachmentType } from '../../../common/utils/attachments/migration_utils'; -import { FILE_ATTACHMENT_TYPE } from '../../../common/constants'; +import { ATTACHMENT_TAB_ALIASES } from './use_case_attachment_tabs'; const getActiveTabId = (tabId?: string) => { if (tabId && Object.values(CASE_VIEW_PAGE_TABS).includes(tabId as CASE_VIEW_PAGE_TABS)) { @@ -34,16 +31,8 @@ const getActiveTabId = (tabId?: string) => { return CASE_VIEW_PAGE_TABS.ACTIVITY; }; -const ATTACHMENT_TABS = [ - CASE_VIEW_PAGE_TABS.ALERTS, - CASE_VIEW_PAGE_TABS.EVENTS, - CASE_VIEW_PAGE_TABS.FILES, - CASE_VIEW_PAGE_TABS.OBSERVABLES, -]; - export const CaseViewPage = React.memo( ({ caseData, refreshRef, actionsNavigation }) => { - const { features, unifiedAttachmentTypeRegistry } = useCasesContext(); const { urlParams } = useUrlParams(); const refreshCaseViewPage = useRefreshCaseViewPage(); @@ -89,31 +78,6 @@ export const CaseViewPage = React.memo( } }, [isLoading, refreshRef, refreshCaseViewPage]); - const owner = Array.isArray(caseData.owner) ? caseData.owner[0] : caseData.owner; - const EventTabComponent = useMemo(() => { - const eventType = toUnifiedAttachmentType('event', owner); - if (!unifiedAttachmentTypeRegistry.has(eventType)) { - return undefined; - } - return unifiedAttachmentTypeRegistry.get(eventType)?.getAttachmentTabViewObject?.()?.children; - }, [unifiedAttachmentTypeRegistry, owner]); - - const AlertTabComponent = useMemo(() => { - const alertType = toUnifiedAttachmentType('alert', owner); - if (!unifiedAttachmentTypeRegistry.has(alertType)) { - return undefined; - } - return unifiedAttachmentTypeRegistry.get(alertType)?.getAttachmentTabViewObject?.()?.children; - }, [unifiedAttachmentTypeRegistry, owner]); - - const FileTabComponent = useMemo(() => { - if (!unifiedAttachmentTypeRegistry.has(FILE_ATTACHMENT_TYPE)) { - return undefined; - } - return unifiedAttachmentTypeRegistry.get(FILE_ATTACHMENT_TYPE)?.getAttachmentTabViewObject?.() - ?.children; - }, [unifiedAttachmentTypeRegistry]); - const onSubmitTitle = useCallback( (newTitle: string) => onUpdateField({ @@ -159,43 +123,13 @@ export const CaseViewPage = React.memo( actionsNavigation={actionsNavigation} /> )} - {ATTACHMENT_TABS.includes(activeTabId as CASE_VIEW_PAGE_TABS) && ( + {ATTACHMENT_TAB_ALIASES.has(activeTabId) && ( - <> - {activeTabId === CASE_VIEW_PAGE_TABS.ALERTS && - features.alerts.enabled && - AlertTabComponent != null && ( - - )} - {activeTabId === CASE_VIEW_PAGE_TABS.EVENTS && - features.events.enabled && - EventTabComponent != null && ( - - )} - {activeTabId === CASE_VIEW_PAGE_TABS.FILES && FileTabComponent != null && ( - - )} - {activeTabId === CASE_VIEW_PAGE_TABS.OBSERVABLES && ( - - )} - - + onUpdateField={onUpdateField} + /> )} {activeTabId === CASE_VIEW_PAGE_TABS.SIMILAR_CASES && ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx index eff787d571c96..2558dad9c478e 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_tabs.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { ReactNode } from 'react'; import React, { useCallback, useMemo } from 'react'; import { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; @@ -12,18 +13,15 @@ import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useCaseViewNavigation } from '../../common/navigation'; import { ACTIVITY_TAB, ATTACHMENTS_TAB, SIMILAR_CASES_TAB } from './translations'; import { type CaseUI } from '../../../common'; -import type { CaseViewTab } from './use_case_attachment_tabs'; import { + ATTACHMENT_TAB_ALIASES, AttachmentsBadge, SimilarCasesBadge, - useCaseAttachmentTabs, + useCaseAttachmentsTotal, } from './use_case_attachment_tabs'; import { useGetSimilarCases } from '../../containers/use_get_similar_cases'; import { useCasesFeatures } from '../../common/use_cases_features'; -import { - useAttachmentsSubTabClickedEBT, - useAttachmentsTabClickedEBT, -} from '../../analytics/use_attachments_tab_ebt'; +import { useAttachmentsTabClickedEBT } from '../../analytics/use_attachments_tab_ebt'; export interface CaseViewTabsProps { caseData: CaseUI; @@ -31,13 +29,15 @@ export interface CaseViewTabsProps { searchTerm?: string; } +interface Tab { + id: CASE_VIEW_PAGE_TABS; + name: string; + badge?: ReactNode; +} + export const CaseViewTabs = React.memo(({ caseData, activeTab, searchTerm }) => { const { navigateToCaseView } = useCaseViewNavigation(); - const { tabs: attachmentTabs, totalAttachments } = useCaseAttachmentTabs({ - caseData, - activeTab, - searchTerm, - }); + const totalAttachments = useCaseAttachmentsTotal({ caseData, searchTerm }); const { euiTheme } = useEuiTheme(); @@ -51,14 +51,9 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab enabled: canShowObservableTabs && isObservablesFeatureEnabled, }); - const isAttachmentsTabActive = useMemo( - () => !!attachmentTabs.find((attachmentTab) => attachmentTab.id === activeTab), - [activeTab, attachmentTabs] - ); - - const defaultAttachmentsTabId = attachmentTabs[0].id; + const isAttachmentsTabActive = ATTACHMENT_TAB_ALIASES.has(activeTab); - const tabs: CaseViewTab[] = useMemo( + const tabs: Tab[] = useMemo( () => [ { id: CASE_VIEW_PAGE_TABS.ACTIVITY, @@ -91,7 +86,6 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab ); const trackAttachmentsTabClick = useAttachmentsTabClickedEBT(); - const trackAttachmentsSubTabClick = useAttachmentsSubTabClickedEBT(); const renderTabs = useCallback(() => { return tabs.map((tab, index) => ( @@ -101,13 +95,11 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab onClick={() => { if (tab.id === CASE_VIEW_PAGE_TABS.ATTACHMENTS) { trackAttachmentsTabClick(); - // NOTE: counting default sub-tab click here as it is already picked when navigating to attachments tab - trackAttachmentsSubTabClick(defaultAttachmentsTabId); } navigateToCaseView({ detailName: caseData.id, - tabId: tab.id === CASE_VIEW_PAGE_TABS.ATTACHMENTS ? CASE_VIEW_PAGE_TABS.ALERTS : tab.id, + tabId: tab.id, }); }} isSelected={ @@ -126,8 +118,6 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab navigateToCaseView, caseData.id, trackAttachmentsTabClick, - trackAttachmentsSubTabClick, - defaultAttachmentsTabId, ]); return {renderTabs()}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.test.tsx new file mode 100644 index 0000000000000..2c23b059400a0 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { AttachmentAccordion } from './attachment_accordion'; +import { renderWithTestingProviders } from '../../../common/mock'; + +describe('AttachmentAccordion', () => { + it('renders the title, count badge, and children', () => { + renderWithTestingProviders( + +
{'content'}
+
+ ); + + expect(screen.getByTestId('case-view-attachment-accordion-alerts')).toBeInTheDocument(); + expect(screen.getByText('Alerts')).toBeInTheDocument(); + expect(screen.getByTestId('case-view-attachment-badge-alerts')).toHaveTextContent('5'); + expect(screen.getByTestId('accordion-content')).toBeInTheDocument(); + }); + + it('namespaces the test subjects and accordion id by the given id', () => { + renderWithTestingProviders( + +
+ + ); + + expect(screen.getByTestId('case-view-attachment-accordion-files')).toBeInTheDocument(); + expect(screen.getByTestId('case-view-attachment-badge-files')).toHaveTextContent('0'); + }); + + it('renders children initially expanded', () => { + renderWithTestingProviders( + +
{'visible'}
+
+ ); + + expect(screen.getByTestId('case-view-attachment-accordion-toggle-observables')).toHaveAttribute( + 'aria-expanded', + 'true' + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx new file mode 100644 index 0000000000000..6775c3e76c884 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiAccordion, + EuiNotificationBadge, + EuiText, + useGeneratedHtmlId, + EuiPanel, + EuiFlexItem, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +interface AttachmentAccordionProps { + id: string; + title: string; + count: number; + children: React.ReactNode; +} + +export const AttachmentAccordion = ({ id, title, count, children }: AttachmentAccordionProps) => { + const { euiTheme } = useEuiTheme(); + const accordionId = useGeneratedHtmlId({ prefix: `case-view-attachment-${id}` }); + return ( + + + +

+ {title} + + {count} + +

+ + } + > + {children} +
+
+
+ ); +}; +AttachmentAccordion.displayName = 'AttachmentAccordion'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx index 2071cfa963474..75c87f0785985 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx @@ -6,18 +6,15 @@ */ import React from 'react'; -import { alertCommentWithIndices, eventComment, basicCase } from '../../../containers/mock'; +import { basicCase, alertComment } from '../../../containers/mock'; import type { CaseUI } from '../../../../common'; import { renderWithTestingProviders } from '../../../common/mock'; import { CaseViewAttachments } from './case_view_attachments'; -import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import { screen, waitFor } from '@testing-library/react'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { useGetCaseFileStats } from '../../../containers/use_get_case_file_stats'; +import { UnifiedAttachmentTypeRegistry } from '../../../client/attachment_framework/unified_attachment_registry'; import userEvent from '@testing-library/user-event'; -import { useCaseViewNavigation } from '../../../common/navigation'; -import * as similarCasesHook from '../../../containers/use_get_similar_cases'; -import { useCaseObservables } from '../use_case_observables'; jest.mock('../../../containers/use_get_case_file_stats'); jest.mock('../../../common/navigation/hooks'); @@ -26,12 +23,42 @@ jest.mock('../use_case_observables', () => ({ })); const useGetCaseFileStatsMock = useGetCaseFileStats as jest.Mock; -const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; -const useGetCaseObservablesMock = useCaseObservables as jest.Mock; -const caseData: CaseUI = { - ...basicCase, - comments: [...basicCase.comments, alertCommentWithIndices], +const caseData: CaseUI = basicCase; + +const buildRegistry = () => { + const registry = new UnifiedAttachmentTypeRegistry(); + registry.register({ + id: 'security.alert', + displayName: 'Alert', + icon: 'bell', + getAttachmentViewObject: () => ({ event: 'added an alert' }), + getAttachmentTabViewObject: () => ({ + children: () =>
{'Alerts table'}
, + }), + schemaValidator: () => {}, + }); + registry.register({ + id: 'security.event', + displayName: 'Event', + icon: 'bell', + getAttachmentViewObject: () => ({ event: 'added an event' }), + getAttachmentTabViewObject: () => ({ + children: () =>
{'Events table'}
, + }), + schemaValidator: () => {}, + }); + registry.register({ + id: 'file', + displayName: 'File', + icon: 'document', + getAttachmentViewObject: () => ({ event: 'added a file' }), + getAttachmentTabViewObject: () => ({ + children: () =>
{'Files table'}
, + }), + schemaValidator: () => {}, + }); + return registry; }; const basicLicense = licensingMock.createLicense({ @@ -44,6 +71,7 @@ const platinumLicense = licensingMock.createLicense({ const fileStatsData = { total: 3 }; const onSearchMock = jest.fn(); +const onUpdateFieldMock = jest.fn(); describe('Case View Attachments tab', () => { beforeEach(() => { @@ -54,12 +82,12 @@ describe('Case View Attachments tab', () => { jest.clearAllMocks(); }); - it('should render the case view attachments tab', async () => { + it('renders the tabs and the search field', async () => { renderWithTestingProviders( ); @@ -67,12 +95,12 @@ describe('Case View Attachments tab', () => { expect(screen.getByTestId('cases-files-search')).toBeInTheDocument(); }); - it('should call the onSearch callback when the search field is changed', async () => { + it('calls the onSearch callback when the search field is changed', async () => { renderWithTestingProviders( ); @@ -83,422 +111,117 @@ describe('Case View Attachments tab', () => { }); }); - it('shows the files tab as active', async () => { + it('does not render an observables accordion when license is basic', async () => { renderWithTestingProviders( , - { - wrapperProps: { license: basicLicense }, - } + { wrapperProps: { license: basicLicense } } ); - expect(await screen.findByTestId('case-view-tab-title-files')).toHaveAttribute( - 'aria-selected', - 'true' - ); - }); - - it('shows the events tab as active', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense, features: { events: { enabled: true } } }, - } - ); - - expect(await screen.findByTestId('case-view-tab-title-events')).toHaveAttribute( - 'aria-selected', - 'true' - ); - }); - - it('shows the files tab with the correct count', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - const badge = await screen.findByTestId('case-view-files-stats-badge'); - - expect(badge).toHaveTextContent('3'); - }); - - it('do not show count on the files tab if the call isLoading', async () => { - useGetCaseFileStatsMock.mockReturnValue({ isLoading: true, data: fileStatsData }); - - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - expect(screen.queryByTestId('case-view-files-stats-badge')).not.toBeInTheDocument(); - }); - - it('shows the alerts tab based on totalAlerts when search is not applied', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - const badge = await screen.findByTestId('case-view-alerts-stats-badge'); - - expect(badge).toHaveTextContent('3'); - }); - - it('shows the alerts tab based on alert comment count when search is applied', async () => { - const alerts = Array.from({ length: 3 }, (_, i) => ({ - ...alertCommentWithIndices, - id: `alert-${i}`, - })); - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - const badge = await screen.findByTestId('case-view-alerts-stats-badge'); - - expect(badge).toHaveTextContent('3'); - }); - - it('the alerts tab count has a different color if the tab is not active', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - expect( - (await screen.findByTestId('case-view-alerts-stats-badge')).getAttribute('class') - ).not.toMatch(/accent/); - }); - - it('navigates to the alerts tab when the alerts tab is clicked', async () => { - const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - await userEvent.click(await screen.findByTestId('case-view-tab-title-alerts')); - - await waitFor(() => { - expect(navigateToCaseViewMock).toHaveBeenCalledWith({ - detailName: caseData.id, - tabId: CASE_VIEW_PAGE_TABS.ALERTS, - }); - }); - }); - - it('navigates to the files tab when the files tab is clicked', async () => { - const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - await userEvent.click(await screen.findByTestId('case-view-tab-title-files')); - - await waitFor(() => { - expect(navigateToCaseViewMock).toHaveBeenCalledWith({ - detailName: caseData.id, - tabId: CASE_VIEW_PAGE_TABS.FILES, - }); - }); - }); - - it('navigates to the events tab when the events tab is clicked', async () => { - const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense, features: { events: { enabled: true } } }, - } - ); - - await userEvent.click(await screen.findByTestId('case-view-tab-title-events')); - - await waitFor(() => { - expect(navigateToCaseViewMock).toHaveBeenCalledWith({ - detailName: caseData.id, - tabId: CASE_VIEW_PAGE_TABS.EVENTS, - }); - }); - }); - - it('should display the alerts tab when the feature is enabled', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense, features: { alerts: { enabled: true } } }, - } - ); - - expect(await screen.findByTestId('case-view-tab-title-alerts')).toBeInTheDocument(); - }); - - it('should not display the alerts tab when the feature is disabled', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense, features: { alerts: { enabled: false } } }, - } - ); - - expect(await screen.findByTestId('case-view-tabs')).toBeInTheDocument(); - expect(screen.queryByTestId('case-view-tab-title-alerts')).not.toBeInTheDocument(); - }); - - it('should not show the experimental badge on the alerts table', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense, features: { alerts: { isExperimental: false } } }, - } - ); - - expect(await screen.findByTestId('case-view-tabs')).toBeInTheDocument(); expect( - screen.queryByTestId('case-view-alerts-table-experimental-badge') + screen.queryByTestId('case-view-attachment-accordion-observables') ).not.toBeInTheDocument(); }); - it('should show the experimental badge on the alerts table', async () => { + it('renders an observables accordion when the license is platinum', async () => { renderWithTestingProviders( , - { - wrapperProps: { license: basicLicense, features: { alerts: { isExperimental: true } } }, - } + { wrapperProps: { license: platinumLicense } } ); expect( - await screen.findByTestId('case-view-alerts-table-experimental-badge') + await screen.findByTestId('case-view-attachment-accordion-observables') ).toBeInTheDocument(); }); - it('should display the events tab based on totalEvents when the feature is enabled and search is not applied', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense, features: { events: { enabled: true } } }, - } - ); - - expect(await screen.findByTestId('case-view-tab-title-events')).toBeInTheDocument(); - - const badge = await screen.findByTestId('case-view-events-stats-badge'); - expect(badge).toHaveTextContent('4'); - }); - - it('should display the events tab with correct count when the feature is enabled', async () => { - const events = Array.from({ length: 2 }, (_, i) => ({ - ...eventComment, - id: `event-${i}`, - })); - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense, features: { events: { enabled: true } } }, - } - ); + it('renders an accordion only for registered types that have a tab view and at least one attachment of that type', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + const caseWithAlert: CaseUI = { ...basicCase, comments: [alertComment] }; - expect(await screen.findByTestId('case-view-tab-title-events')).toBeInTheDocument(); - - const badge = await screen.findByTestId('case-view-events-stats-badge'); - expect(badge).toHaveTextContent('2'); - }); - - it('should not display the events tab when the feature is disabled', async () => { renderWithTestingProviders( , - { - wrapperProps: { license: basicLicense, features: { events: { enabled: false } } }, - } + { wrapperProps: { unifiedAttachmentTypeRegistry } } ); - expect(await screen.findByTestId('case-view-tabs')).toBeInTheDocument(); - expect(screen.queryByTestId('case-view-tab-title-events')).not.toBeInTheDocument(); + expect(screen.getByTestId('case-view-attachment-accordion-security.alert')).toBeInTheDocument(); + expect( + screen.queryByTestId('case-view-attachment-accordion-security.event') + ).not.toBeInTheDocument(); }); - it('should not show observable tabs in non-platinum tiers', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: basicLicense }, - } - ); - - expect(screen.queryByTestId('case-view-tab-title-observables')).not.toBeInTheDocument(); - }); + it('hides the files accordion when there are no files even if the file type is registered', () => { + useGetCaseFileStatsMock.mockReturnValue({ data: { total: 0 } }); + const unifiedAttachmentTypeRegistry = buildRegistry(); - it('should not show observable tabs if the observables feature is not enabled', async () => { renderWithTestingProviders( , - { - wrapperProps: { - license: basicLicense, - features: { observables: { enabled: false, autoExtract: false } }, - }, - } + { wrapperProps: { unifiedAttachmentTypeRegistry } } ); - expect(screen.queryByTestId('case-view-tab-title-observables')).not.toBeInTheDocument(); + expect(screen.queryByTestId('case-view-attachment-accordion-file')).not.toBeInTheDocument(); }); - it('should show observable tabs in platinum+ tiers', async () => { - const spyOnUseGetSimilarCases = jest.spyOn(similarCasesHook, 'useGetSimilarCases'); + it('renders the files accordion when fileStats reports files', () => { + useGetCaseFileStatsMock.mockReturnValue({ data: { total: 2 } }); + const unifiedAttachmentTypeRegistry = buildRegistry(); renderWithTestingProviders( , - { - wrapperProps: { license: platinumLicense }, - } + { wrapperProps: { unifiedAttachmentTypeRegistry } } ); - // NOTE: ensure we are calling the hook but the fetching is enabled (based on the license) - expect(spyOnUseGetSimilarCases).toHaveBeenLastCalledWith( - expect.objectContaining({ enabled: true }) - ); + expect(screen.getByTestId('case-view-attachment-accordion-file')).toBeInTheDocument(); }); - it('should show the observables tab', async () => { - renderWithTestingProviders( - , - { - wrapperProps: { license: platinumLicense }, - } - ); + it('renders accordions in alphabetical order by display name', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + // 2 alerts + 1 event so all three registry-driven accordions render + // (Alert, Event, File via fileStats). + const caseWithComments: CaseUI = { + ...basicCase, + comments: [ + alertComment, + { ...alertComment, id: 'alert-2' }, + { ...alertComment, id: 'evt-1', type: 'event' as never }, + ], + }; - expect(await screen.findByTestId('case-view-tab-title-observables')).toBeInTheDocument(); - }); - it('shows the observables tab with the correct count', async () => { renderWithTestingProviders( , - { - wrapperProps: { license: platinumLicense }, - } + { wrapperProps: { unifiedAttachmentTypeRegistry } } ); - const badge = await screen.findByTestId('case-view-observables-stats-badge'); - - expect(badge).toHaveTextContent('0'); - }); - - it('do not show count on the observables tab if the call isLoading', async () => { - useGetCaseObservablesMock.mockReturnValue({ isLoading: true, observables: [] }); - - renderWithTestingProviders( - , - { - wrapperProps: { license: platinumLicense }, - } - ); + const ids = screen + .getAllByTestId(/^case-view-attachment-accordion-(security\.alert|security\.event|file)$/) + .map((el) => el.getAttribute('data-test-subj')); - expect(screen.queryByTestId('case-view-observables-stats-badge')).not.toBeInTheDocument(); + expect(ids).toEqual([ + 'case-view-attachment-accordion-security.alert', // Alert + 'case-view-attachment-accordion-security.event', // Event + 'case-view-attachment-accordion-file', // File + ]); }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx index be680e279f4d5..07409bd35280e 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx @@ -5,96 +5,82 @@ * 2.0. */ -import type { EuiSelectableOption } from '@elastic/eui'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiSelectable, - EuiSpacer, - EuiTitle, - EuiFieldSearch, -} from '@elastic/eui'; -import type { PropsWithChildren } from 'react'; +import { EuiFieldSearch, EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import React, { useMemo } from 'react'; -import { useAttachmentsSubTabClickedEBT } from '../../../analytics/use_attachments_tab_ebt'; -import { useCaseViewNavigation } from '../../../common/navigation'; import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import type { CaseUI } from '../../../../common'; -import { CaseViewTabs } from '../case_view_tabs'; -import { useCaseAttachmentTabs } from '../use_case_attachment_tabs'; -import { - ALERTS_TAB, - ATTACHMENTS_TAB, - EVENTS_TAB, - FILES_TAB, - OBSERVABLES_TAB, -} from '../translations'; +import { FILE_ATTACHMENT_TYPE } from '../../../../common/constants'; +import { toUnifiedAttachmentType } from '../../../../common/utils/attachments/migration_utils'; +import { useCasesContext } from '../../cases_context/use_cases_context'; +import { useCasesFeatures } from '../../../common/use_cases_features'; import { SEARCH_PLACEHOLDER } from '../../actions/translations'; import { CaseViewAttachButton } from './case_view_attach_button'; +import { CaseViewTabs } from '../case_view_tabs'; +import { OBSERVABLES_TAB } from '../translations'; +import { CaseViewObservables } from './case_view_observables'; +import type { OnUpdateFields } from '../types'; +import { AttachmentAccordion } from './attachment_accordion'; +import { useGetCaseFileStats } from '../../../containers/use_get_case_file_stats'; -const translateTitle = (activeTab: CASE_VIEW_PAGE_TABS) => { - switch (activeTab) { - case CASE_VIEW_PAGE_TABS.ALERTS: { - return ALERTS_TAB; - } - - case CASE_VIEW_PAGE_TABS.EVENTS: { - return EVENTS_TAB; - } - - case CASE_VIEW_PAGE_TABS.FILES: { - return FILES_TAB; - } - - case CASE_VIEW_PAGE_TABS.OBSERVABLES: { - return OBSERVABLES_TAB; - } - - // NOTE:this should not be called - default: - return ATTACHMENTS_TAB; - } -}; +interface CaseViewAttachmentsProps { + caseData: CaseUI; + onSearch: (searchTerm: string) => void; + searchTerm?: string; + onUpdateField: (args: OnUpdateFields) => void; +} export const CaseViewAttachments = ({ caseData, - activeTab, onSearch, searchTerm, - children, -}: PropsWithChildren<{ - caseData: CaseUI; - activeTab: CASE_VIEW_PAGE_TABS; - onSearch: (searchTerm: string) => void; - searchTerm?: string; -}>) => { - const { tabs: caseViewTabs } = useCaseAttachmentTabs({ caseData, activeTab, searchTerm }); - const { navigateToCaseView } = useCaseViewNavigation(); - const trackSubTabClick = useAttachmentsSubTabClickedEBT(); + onUpdateField, +}: CaseViewAttachmentsProps) => { + const { unifiedAttachmentTypeRegistry } = useCasesContext(); + const { observablesAuthorized, isObservablesFeatureEnabled } = useCasesFeatures(); + const { data: fileStats } = useGetCaseFileStats({ caseId: caseData.id, searchTerm }); + const fileCount = fileStats?.total ?? 0; + + const owner = Array.isArray(caseData.owner) ? caseData.owner[0] : caseData.owner; - const tabAsSelectableOptions = useMemo(() => { - return caseViewTabs.map( - (tab) => - ({ - label: tab.name, - 'data-test-subj': `case-view-tab-title-${tab.id}`, - append: tab.badge, - checked: tab.id === activeTab ? 'on' : undefined, - onFocusBadge: false, - showIcons: false, - onClick: () => { - navigateToCaseView({ detailName: caseData.id, tabId: tab.id }); - trackSubTabClick(tab.id); - }, - } as EuiSelectableOption) - ); - }, [caseViewTabs, activeTab, navigateToCaseView, caseData.id, trackSubTabClick]); + const countsByType = useMemo(() => { + const counts = new Map(); + for (const comment of caseData.comments) { + const unifiedType = toUnifiedAttachmentType(comment.type, owner); + counts.set(unifiedType, (counts.get(unifiedType) ?? 0) + 1); + } + return counts; + }, [caseData.comments, owner]); + + // Render one accordion per registered type that has a tab view AND a non-zero count + const attachmentSections = useMemo( + () => + unifiedAttachmentTypeRegistry + .list() + .flatMap((type) => { + const Children = type.getAttachmentTabViewObject?.()?.children; + if (!Children) return []; + // file count rely on file client as source of truth + const count = + type.id === FILE_ATTACHMENT_TYPE ? fileCount : countsByType.get(type.id) ?? 0; + if (count < 1) return []; + return [{ id: type.id, displayName: type.displayName, count, Children }]; + }) + .sort((a, b) => a.displayName.localeCompare(b.displayName)), + [unifiedAttachmentTypeRegistry, countsByType, fileCount] + ); + + // Observables stays hardcoded: there's no other entry point to add observables + // to a case, so the accordion must be visible even when the count is 0. + const showObservables = observablesAuthorized && isObservablesFeatureEnabled; return ( <> - - + + @@ -110,19 +96,26 @@ export const CaseViewAttachments = ({ - - - - {(list) => list} - - - - -

{translateTitle(activeTab)}

-
- - {children} -
+ + {attachmentSections.map(({ id, displayName, count, Children }) => ( + + + + ))} + {showObservables && ( + + + + )}
diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts index 3479a303b11f0..242c19fd7ec45 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts @@ -190,18 +190,6 @@ export const ATTACHMENTS_TAB = i18n.translate('xpack.cases.caseView.tabs.attachm defaultMessage: 'Attachments', }); -export const ALERTS_TAB = i18n.translate('xpack.cases.caseView.tabs.alerts', { - defaultMessage: 'Alerts', -}); - -export const EVENTS_TAB = i18n.translate('xpack.cases.caseView.tabs.events', { - defaultMessage: 'Events', -}); - -export const FILES_TAB = i18n.translate('xpack.cases.caseView.tabs.files', { - defaultMessage: 'Files', -}); - export const OBSERVABLES_TAB = i18n.translate('xpack.cases.caseView.tabs.observables', { defaultMessage: 'Observables', }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx index 80cc71e12662e..afe77205ed31b 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx @@ -5,102 +5,138 @@ * 2.0. */ -import { caseData } from './mocks'; - -import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; -import type { CaseViewTabsProps } from './case_view_tabs'; -import { useCaseAttachmentTabs } from './use_case_attachment_tabs'; -import { renderHook } from '@testing-library/react'; -import { TestProviders } from '../../common/mock'; import React from 'react'; - +import { renderHook } from '@testing-library/react'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; -import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; -const platinumLicense = licensingMock.createLicense({ - license: { type: 'platinum' }, -}); +import { basicCase, alertComment } from '../../containers/mock'; +import type { CaseUI } from '../../../common'; +import { TestProviders } from '../../common/mock'; +import { useCaseAttachmentsTotal } from './use_case_attachment_tabs'; +import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; +import { useCaseObservables } from './use_case_observables'; +import { UnifiedAttachmentTypeRegistry } from '../../client/attachment_framework/unified_attachment_registry'; jest.mock('../../containers/use_get_case_file_stats'); - -jest.mock('./use_case_observables', () => ({ - useCaseObservables: () => ({ - observables: [], - isLoading: false, - }), -})); +jest.mock('./use_case_observables'); const useGetCaseFileStatsMock = useGetCaseFileStats as jest.Mock; +const useCaseObservablesMock = useCaseObservables as jest.Mock; + +const platinumLicense = licensingMock.createLicense({ license: { type: 'platinum' } }); +const basicLicense = licensingMock.createLicense({ license: { type: 'basic' } }); + +const buildRegistry = () => { + const registry = new UnifiedAttachmentTypeRegistry(); + registry.register({ + id: 'security.alert', + displayName: 'Alert', + icon: 'bell', + getAttachmentViewObject: () => ({ event: 'added an alert' }), + getAttachmentTabViewObject: () => ({ + children: () =>
{'Alerts'}
, + }), + schemaValidator: () => {}, + }); + // File type is intentionally registered: the hook must NOT count it from + // comments — files are counted via the file stats API instead. + registry.register({ + id: 'file', + displayName: 'File', + icon: 'document', + getAttachmentViewObject: () => ({ event: 'added a file' }), + getAttachmentTabViewObject: () => ({ + children: () =>
, + }), + schemaValidator: () => {}, + }); + return registry; +}; -const fileStatsData = { total: 3 }; +describe('useCaseAttachmentsTotal', () => { + beforeEach(() => { + useGetCaseFileStatsMock.mockReturnValue({ data: { total: 3 } }); + useCaseObservablesMock.mockReturnValue({ observables: [], isLoading: false }); + }); -export const caseProps: CaseViewTabsProps = { - caseData, - activeTab: CASE_VIEW_PAGE_TABS.ACTIVITY, -}; + afterEach(() => { + jest.clearAllMocks(); + }); -export const casePropsWithAlerts: CaseViewTabsProps = { - ...caseProps, - caseData: { ...caseData, totalAlerts: 3 }, -}; + it('sums comments matching registered types (with a tab view) + file stats', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + const caseWithAlert: CaseUI = { ...basicCase, comments: [alertComment] }; -export const casePropsWithEvents: CaseViewTabsProps = { - ...caseProps, - caseData: { ...caseData, totalEvents: 4 }, -}; + const { result } = renderHook(() => useCaseAttachmentsTotal({ caseData: caseWithAlert }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); -describe('useCaseAttachmentTabs()', () => { - beforeEach(() => { - useGetCaseFileStatsMock.mockReturnValue({ data: fileStatsData }); + // 1 alert comment (via security.alert) + 3 files = 4 + expect(result.current).toBe(4); + }); + + it('does not double-count file comments — files come from fileStats only', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + + const { result } = renderHook(() => useCaseAttachmentsTotal({ caseData: basicCase }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // basicCase has only a user comment (no registered type with tab view). + // File comments would otherwise be tempting to count, but only fileStats contributes. + expect(result.current).toBe(3); }); - it('returns basic case attachment tabs', async () => { - const { result } = renderHook( - () => { - return useCaseAttachmentTabs({ caseData, activeTab: CASE_VIEW_PAGE_TABS.ALERTS }); - }, - { - wrapper: ({ children }) => {children}, - } - ); - - expect(result.current.tabs.map((tab) => tab.id)).toMatchInlineSnapshot(` - Array [ - "alerts", - "files", - ] - `); - expect(result.current.totalAttachments).toEqual(3); + it('adds observables when the feature is enabled and license is platinum', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + useCaseObservablesMock.mockReturnValue({ + observables: [{ id: '1' }, { id: '2' }], + isLoading: false, + }); + + const { result } = renderHook(() => useCaseAttachmentsTotal({ caseData: basicCase }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // 0 from comments + 3 files + 2 observables = 5 + expect(result.current).toBe(5); }); - it('returns attachment tabs based on enable features an license', async () => { - const { result } = renderHook( - () => { - return useCaseAttachmentTabs({ caseData, activeTab: CASE_VIEW_PAGE_TABS.ALERTS }); - }, - { - wrapper: ({ children }) => ( - - {children} - - ), - } - ); - - expect(result.current.tabs.map((tab) => tab.id)).toMatchInlineSnapshot(` - Array [ - "alerts", - "events", - "files", - "observables", - ] - `); + it('excludes observables when the license is not platinum', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + useCaseObservablesMock.mockReturnValue({ + observables: [{ id: '1' }, { id: '2' }], + isLoading: false, + }); + + const { result } = renderHook(() => useCaseAttachmentsTotal({ caseData: basicCase }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(3); }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx index ce973e7ddc6e8..6b0faea1988a5 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx @@ -6,80 +6,29 @@ */ import type { EuiThemeComputed } from '@elastic/eui'; -import { EuiNotificationBadge, useEuiTheme } from '@elastic/eui'; -import type { ReactNode } from 'react'; +import { EuiNotificationBadge } from '@elastic/eui'; import React, { useMemo } from 'react'; import { css } from '@emotion/react'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { ALERTS_TAB, EVENTS_TAB, FILES_TAB, OBSERVABLES_TAB } from './translations'; import { type CaseUI } from '../../../common'; import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; import { useCaseObservables } from './use_case_observables'; -import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; import { useCasesFeatures } from '../../common/use_cases_features'; -import { AttachmentType } from '../../../common/types/domain'; -import { - isEventAttachmentType, - isLegacyAttachmentRequest, -} from '../../../common/utils/attachments'; -import { isUnifiedEventAttachment } from '../../../common/utils/attachments/v2_type_guards'; +import { toUnifiedAttachmentType } from '../../../common/utils/attachments/migration_utils'; +import { FILE_ATTACHMENT_TYPE } from '../../../common/constants'; -const FilesBadge = ({ - activeTab, - fileStatsData, - isLoading, - euiTheme, -}: { - activeTab: string; - fileStatsData: { total: number } | undefined; - isLoading: boolean; - euiTheme: EuiThemeComputed<{}>; -}) => ( - <> - {!isLoading && fileStatsData && ( - - {fileStatsData.total > 0 ? fileStatsData.total : 0} - - )} - -); - -FilesBadge.displayName = 'FilesBadge'; - -const ObservablesBadge = ({ - activeTab, - isLoading, - euiTheme, - count, -}: { - activeTab: string; - count: number; - isLoading: boolean; - euiTheme: EuiThemeComputed<{}>; -}) => ( - <> - {!isLoading && ( - - {count} - - )} - -); - -ObservablesBadge.displayName = 'ObservablesBadge'; +/** + * Tab ids that resolve to the consolidated attachments view. Includes the + * legacy per-type sub-tab ids so deep links from older URLs still work. + */ +export const ATTACHMENT_TAB_ALIASES: ReadonlySet = new Set([ + CASE_VIEW_PAGE_TABS.ATTACHMENTS, + CASE_VIEW_PAGE_TABS.ALERTS, + CASE_VIEW_PAGE_TABS.EVENTS, + CASE_VIEW_PAGE_TABS.FILES, + CASE_VIEW_PAGE_TABS.OBSERVABLES, +]); export const SimilarCasesBadge = ({ activeTab, @@ -90,21 +39,16 @@ export const SimilarCasesBadge = ({ count?: number; euiTheme: EuiThemeComputed<{}>; }) => ( - <> - { - - {count ?? 0} - - } - + + {count ?? 0} + ); - SimilarCasesBadge.displayName = 'SimilarCasesBadge'; export const AttachmentsBadge = ({ @@ -115,227 +59,70 @@ export const AttachmentsBadge = ({ isActive: boolean; count?: number; euiTheme: EuiThemeComputed<{}>; -}) => ( - <> - { - - {count ?? 0} - - } - -); - -AttachmentsBadge.displayName = 'AttachmentsBadge'; - -const AlertsBadge = ({ - activeTab, - totalAlerts, - isExperimental, - euiTheme, -}: { - activeTab: string; - totalAlerts: number | undefined; - isExperimental: boolean; - euiTheme: EuiThemeComputed<{}>; -}) => ( - <> - - {totalAlerts || 0} - - {isExperimental && ( - - )} - -); - -AlertsBadge.displayName = 'AlertsBadge'; - -const EventsBadge = ({ - activeTab, - totalEvents, - euiTheme, -}: { - activeTab: string; - totalEvents: number | undefined; - euiTheme: EuiThemeComputed<{}>; }) => ( - {totalEvents || 0} + {count ?? 0} ); +AttachmentsBadge.displayName = 'AttachmentsBadge'; -EventsBadge.displayName = 'EventsBadge'; - -export interface CaseViewTab { - badge?: ReactNode; - id: CASE_VIEW_PAGE_TABS; - name: string; -} - -export interface UseCaseAttachmentTabsReturnValue { - tabs: CaseViewTab[]; - totalAttachments: number; -} - -export const useCaseAttachmentTabs = ({ +/** + * Computes the total count shown on the top-level "Attachments" tab badge. + * Matches what `CaseViewAttachments` actually renders: one row per attachment + * whose registered type has a tab view (files excluded — counted via + * `fileStatsData`), plus files and (license-permitting) observables. + */ +export const useCaseAttachmentsTotal = ({ caseData, - activeTab, searchTerm, }: { caseData: CaseUI; - activeTab: CASE_VIEW_PAGE_TABS; searchTerm?: string; -}): UseCaseAttachmentTabsReturnValue => { - const { features } = useCasesContext(); - const { euiTheme } = useEuiTheme(); - const { data: fileStatsData, isLoading: isLoadingFiles } = useGetCaseFileStats({ - caseId: caseData.id, - searchTerm, - }); - - const showAlertsTab = features.alerts.enabled; - const showEventsTab = features.events.enabled; - - const { observables, isLoading: isLoadingObservables } = useCaseObservables(caseData, searchTerm); +}): number => { + const { unifiedAttachmentTypeRegistry } = useCasesContext(); + const { data: fileStatsData } = useGetCaseFileStats({ caseId: caseData.id, searchTerm }); + const { observables } = useCaseObservables(caseData, searchTerm); const { observablesAuthorized: canShowObservableTabs, isObservablesFeatureEnabled } = useCasesFeatures(); - const stats = useMemo(() => { - if (!searchTerm) { - return { - totalAlerts: Number(caseData.totalAlerts), - totalEvents: Number(caseData.totalEvents), - }; - } - return caseData.comments.reduce( - (acc, comment) => { - if ( - isLegacyAttachmentRequest(comment) && - comment.type === AttachmentType.alert && - features.alerts.enabled - ) { - acc.totalAlerts = Array.isArray(comment.alertId) - ? acc.totalAlerts + comment.alertId.length - : acc.totalAlerts + 1; - } else if (isEventAttachmentType(comment.type) && features.events.enabled) { - if (isLegacyAttachmentRequest(comment) && comment.type === AttachmentType.event) { - acc.totalEvents = Array.isArray(comment.eventId) - ? acc.totalEvents + comment.eventId.length - : acc.totalEvents + 1; - } else if (isUnifiedEventAttachment(comment)) { - acc.totalEvents = Array.isArray(comment.attachmentId) - ? acc.totalEvents + comment.attachmentId.length - : acc.totalEvents + 1; - } - } - return acc; - }, - { totalEvents: 0, totalAlerts: 0 } + return useMemo(() => { + const owner = Array.isArray(caseData.owner) ? caseData.owner[0] : caseData.owner; + const typesWithTabView = new Set( + unifiedAttachmentTypeRegistry + .list() + .filter( + (type) => + type.id !== FILE_ATTACHMENT_TYPE && + type.getAttachmentTabViewObject?.()?.children != null + ) + .map((type) => type.id) ); - }, [searchTerm, features, caseData]); - const totalAttachments = - stats.totalAlerts + - stats.totalEvents + - Number(fileStatsData?.total ?? 0) + - (canShowObservableTabs && isObservablesFeatureEnabled ? observables.length : 0); - - const tabsConfig = useMemo( - () => [ - ...(showAlertsTab - ? [ - { - id: CASE_VIEW_PAGE_TABS.ALERTS, - name: ALERTS_TAB, - badge: ( - - ), - }, - ] - : []), - ...(showEventsTab - ? [ - { - id: CASE_VIEW_PAGE_TABS.EVENTS, - name: EVENTS_TAB, - badge: ( - - ), - }, - ] - : []), - { - id: CASE_VIEW_PAGE_TABS.FILES, - name: FILES_TAB, - badge: ( - - ), - }, - ...(canShowObservableTabs && isObservablesFeatureEnabled - ? [ - { - id: CASE_VIEW_PAGE_TABS.OBSERVABLES, - name: OBSERVABLES_TAB, - badge: ( - - ), - }, - ] - : []), - ], - [ - activeTab, - canShowObservableTabs, - stats.totalAlerts, - stats.totalEvents, - euiTheme, - showAlertsTab, - showEventsTab, - features.alerts.isExperimental, - fileStatsData, - isLoadingFiles, - isLoadingObservables, - isObservablesFeatureEnabled, - observables.length, - ] - ); + let registryTotal = 0; + for (const comment of caseData.comments) { + if (typesWithTabView.has(toUnifiedAttachmentType(comment.type, owner))) { + registryTotal += 1; + } + } - return { tabs: tabsConfig, totalAttachments }; + return ( + registryTotal + + Number(fileStatsData?.total ?? 0) + + (canShowObservableTabs && isObservablesFeatureEnabled ? observables.length : 0) + ); + }, [ + caseData.owner, + caseData.comments, + unifiedAttachmentTypeRegistry, + fileStatsData?.total, + canShowObservableTabs, + isObservablesFeatureEnabled, + observables.length, + ]); }; diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 4ccbcd920b32e..7b2b7ac27a1db 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -913,9 +913,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('adds a file to the case', async () => { - // navigate to files tab - await testSubjects.click('case-view-tab-title-files'); - await testSubjects.existOrFail('case-view-tab-content-files'); + await testSubjects.existOrFail('case-view-attachment-accordion-file'); await cases.casesFilesTable.addFile(require.resolve('./elastic_logo.png')); diff --git a/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts b/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts index 4e1d31e848449..eddbcff6f5062 100644 --- a/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts +++ b/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts @@ -397,8 +397,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('adds a file to the case', async () => { - await testSubjects.click('case-view-tab-title-files'); - await testSubjects.existOrFail('case-view-tab-content-files'); + await testSubjects.existOrFail('case-view-attachment-accordion-file'); await cases.casesFilesTable.addFile(require.resolve('./note.txt')); diff --git a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts index fdee386206087..4bf402029e074 100644 --- a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts +++ b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts @@ -394,8 +394,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('case-view-tab-title-attachments'); }); it('adds a file to the case', async () => { - await testSubjects.click('case-view-tab-title-files'); - await testSubjects.existOrFail('case-view-tab-content-files'); + await testSubjects.existOrFail('case-view-attachment-accordion-file'); await cases.casesFilesTable.addFile(require.resolve('./note.txt')); From 0dcd661ff8d65b55411cc0c678275d7f1d0d2c7a Mon Sep 17 00:00:00 2001 From: christineweng Date: Tue, 26 May 2026 17:06:48 -0500 Subject: [PATCH 2/8] fix height and ftr tests --- .../components/attachment_accordion.tsx | 10 ++++++--- .../components/case_view_observables.tsx | 22 +++++-------------- .../apps/cases/group1/view_case.ts | 2 -- .../stack_cases/details_view.ts | 5 +++-- .../observability_cases/list_view.ts | 3 +-- .../functional/test_suites/cases/view_case.ts | 2 -- .../response_ops_docs/cases/list_view.ts | 2 -- .../security_cases/list_view.ts | 5 +++-- .../test_suites/ftr/cases/view_case.ts | 2 -- .../response_ops_docs/cases/list_view.ts | 2 -- 10 files changed, 19 insertions(+), 36 deletions(-) diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx index 6775c3e76c884..3b46292adb682 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/attachment_accordion.tsx @@ -15,7 +15,7 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import React from 'react'; +import React, { useCallback, useState } from 'react'; interface AttachmentAccordionProps { id: string; @@ -27,6 +27,9 @@ interface AttachmentAccordionProps { export const AttachmentAccordion = ({ id, title, count, children }: AttachmentAccordionProps) => { const { euiTheme } = useEuiTheme(); const accordionId = useGeneratedHtmlId({ prefix: `case-view-attachment-${id}` }); + // Controlled isOpen so we can fully unmount children when collapsed + const [isOpen, setIsOpen] = useState(true); + const onToggle = useCallback((nextIsOpen: boolean) => setIsOpen(nextIsOpen), []); return ( @@ -34,7 +37,8 @@ export const AttachmentAccordion = ({ id, title, count, children }: AttachmentAc id={accordionId} data-test-subj={`case-view-attachment-accordion-${id}`} buttonProps={{ 'data-test-subj': `case-view-attachment-accordion-toggle-${id}` }} - initialIsOpen + forceState={isOpen ? 'open' : 'closed'} + onToggle={onToggle} buttonContent={

} > - {children} + {isOpen ? children : null} diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx index 149955b0fb2e7..6a1c71716cfa3 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx @@ -5,11 +5,7 @@ * 2.0. */ import React, { useCallback, useMemo } from 'react'; - -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; - import type { CaseUI } from '../../../../common/ui/types'; - import { ObservablesTable } from '../../observables/observables_table'; import { useCaseObservables } from '../use_case_observables'; import type { OnUpdateFields } from '../types'; @@ -47,19 +43,11 @@ export const CaseViewObservables = ({ ); return ( - - - - - - - - - + ); }; diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 7b2b7ac27a1db..7e660f6a5537f 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -913,8 +913,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('adds a file to the case', async () => { - await testSubjects.existOrFail('case-view-attachment-accordion-file'); - await cases.casesFilesTable.addFile(require.resolve('./elastic_logo.png')); // make sure the uploaded file is displayed on the table diff --git a/x-pack/platform/test/screenshot_creation/apps/response_ops_docs/stack_cases/details_view.ts b/x-pack/platform/test/screenshot_creation/apps/response_ops_docs/stack_cases/details_view.ts index 4d1eb238b4d1e..d8e0425934279 100644 --- a/x-pack/platform/test/screenshot_creation/apps/response_ops_docs/stack_cases/details_view.ts +++ b/x-pack/platform/test/screenshot_creation/apps/response_ops_docs/stack_cases/details_view.ts @@ -31,8 +31,9 @@ export default function ({ getService }: FtrProviderContext) { 1400, 1024 ); - const filesTab = await testSubjects.find('case-view-tab-title-files'); - await filesTab.click(); + const attachmentsTab = await testSubjects.find('case-view-tab-title-attachments'); + await attachmentsTab.click(); + await testSubjects.existOrFail('case-view-attachment-accordion-file'); await commonScreenshots.takeScreenshot('cases-files', screenshotDirectories, 1400, 1024); }); }); diff --git a/x-pack/solutions/observability/test/screenshot_creation/apps/response_ops_docs/observability_cases/list_view.ts b/x-pack/solutions/observability/test/screenshot_creation/apps/response_ops_docs/observability_cases/list_view.ts index 1943d408998a7..5a368ae482705 100644 --- a/x-pack/solutions/observability/test/screenshot_creation/apps/response_ops_docs/observability_cases/list_view.ts +++ b/x-pack/solutions/observability/test/screenshot_creation/apps/response_ops_docs/observability_cases/list_view.ts @@ -89,8 +89,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await common.navigateToUrlWithBrowserHistory('observability', `/cases/${caseIdMonitoring}`); const attachmentsTab = await testSubjects.find('case-view-tab-title-attachments'); await attachmentsTab.click(); - const filesTab = await testSubjects.find('case-view-tab-title-files'); - await filesTab.click(); + await testSubjects.existOrFail('case-view-attachment-accordion-file'); await commonScreenshots.takeScreenshot( 'observabiity-case-files', screenshotDirectories, diff --git a/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts b/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts index eddbcff6f5062..1589dad079f5f 100644 --- a/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts +++ b/x-pack/solutions/observability/test/serverless/functional/test_suites/cases/view_case.ts @@ -397,8 +397,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('adds a file to the case', async () => { - await testSubjects.existOrFail('case-view-attachment-accordion-file'); - await cases.casesFilesTable.addFile(require.resolve('./note.txt')); const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); diff --git a/x-pack/solutions/observability/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts b/x-pack/solutions/observability/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts index b1af37e275edf..dbde4cdfbf0e2 100644 --- a/x-pack/solutions/observability/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts +++ b/x-pack/solutions/observability/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts @@ -78,8 +78,6 @@ export default function ({ getPageObject, getPageObjects, getService }: FtrProvi await pageObjects.svlCommonNavigation.sidenav.toggle(true); const attachmentsTab = await testSubjects.find('case-view-tab-title-attachments'); await attachmentsTab.click(); - const filesTab = await testSubjects.find('case-view-tab-title-files'); - await filesTab.click(); await cases.casesFilesTable.addFile(require.resolve('./testfile.png')); await testSubjects.getVisibleText('cases-files-name-link'); await svlCommonScreenshots.takeScreenshot( diff --git a/x-pack/solutions/security/test/screenshot_creation/apps/response_ops_docs/security_cases/list_view.ts b/x-pack/solutions/security/test/screenshot_creation/apps/response_ops_docs/security_cases/list_view.ts index 2d4ba8e29f747..18e5749b61835 100644 --- a/x-pack/solutions/security/test/screenshot_creation/apps/response_ops_docs/security_cases/list_view.ts +++ b/x-pack/solutions/security/test/screenshot_creation/apps/response_ops_docs/security_cases/list_view.ts @@ -78,8 +78,9 @@ export default function ({ getPageObject, getService, getPageObjects }: FtrProvi }); await commonScreenshots.takeScreenshot('cases-ui-open', screenshotDirectories, 1400, 1024); - const filesTab = await testSubjects.find('case-view-tab-title-files'); - await filesTab.click(); + const attachmentsTab = await testSubjects.find('case-view-tab-title-attachments'); + await attachmentsTab.click(); + await testSubjects.existOrFail('case-view-attachment-accordion-file'); await commonScreenshots.takeScreenshot('cases-files', screenshotDirectories, 1400, 1024); }); }); diff --git a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts index 4bf402029e074..77c50df0d63df 100644 --- a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts +++ b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cases/view_case.ts @@ -394,8 +394,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('case-view-tab-title-attachments'); }); it('adds a file to the case', async () => { - await testSubjects.existOrFail('case-view-attachment-accordion-file'); - await cases.casesFilesTable.addFile(require.resolve('./note.txt')); const uploadedFileName = await testSubjects.getVisibleText('cases-files-name-text'); diff --git a/x-pack/solutions/security/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts b/x-pack/solutions/security/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts index aa11c5901909a..36647f43ec155 100644 --- a/x-pack/solutions/security/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts +++ b/x-pack/solutions/security/test/serverless/functional/test_suites/screenshot_creation/response_ops_docs/cases/list_view.ts @@ -86,8 +86,6 @@ export default function ({ getPageObject, getPageObjects, getService }: FtrProvi await svlCommonScreenshots.takeScreenshot('cases-ui-open', screenshotDirectories, 1400, 1024); const attachmentsTab = await testSubjects.find('case-view-tab-title-attachments'); await attachmentsTab.click(); - const filesTab = await testSubjects.find('case-view-tab-title-files'); - await filesTab.click(); await cases.casesFilesTable.addFile(require.resolve('./testfile.png')); await testSubjects.getVisibleText('cases-files-name-link'); await svlCommonScreenshots.takeScreenshot('cases-files', screenshotDirectories, 1400, 1024); From 1fb851763559c603c9b2087b6244842af937911e Mon Sep 17 00:00:00 2001 From: christineweng Date: Wed, 27 May 2026 10:58:46 -0500 Subject: [PATCH 3/8] fix tests --- x-pack/platform/test/functional/services/cases/files.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/test/functional/services/cases/files.ts b/x-pack/platform/test/functional/services/cases/files.ts index 677fc1df22a5a..e13df8bc1cccc 100644 --- a/x-pack/platform/test/functional/services/cases/files.ts +++ b/x-pack/platform/test/functional/services/cases/files.ts @@ -21,8 +21,9 @@ export function CasesFilesTableServiceProvider({ getService, getPageObject }: Ft return { async addFile(fileInputPath: string) { - // click the AddFile button - await testSubjects.click('cases-files-add'); + // open the Attach popover and choose the file option + await testSubjects.click('case-view-attach-button'); + await testSubjects.click('case-view-attach-menu-file'); await find.byCssSelector('[aria-label="Upload a file"]'); // upload a file From d45de7b53a03f7ecdb0200199735919637b8955a Mon Sep 17 00:00:00 2001 From: christineweng Date: Wed, 27 May 2026 16:03:16 -0500 Subject: [PATCH 4/8] fix search count and tests --- .../components/case_view_attachments.test.tsx | 28 ++++++++- .../components/case_view_attachments.tsx | 26 ++++----- .../components/case_view_observables.tsx | 19 ++++-- .../case_view/components/helpers.test.ts | 58 ++++++++++++++++++- .../case_view/components/helpers.ts | 16 ++++- .../use_case_attachment_tabs.test.tsx | 21 +++++++ .../case_view/use_case_attachment_tabs.tsx | 9 +-- .../test/functional/services/cases/files.ts | 5 +- 8 files changed, 149 insertions(+), 33 deletions(-) diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx index 75c87f0785985..93f9f3dd71af0 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx @@ -160,7 +160,7 @@ describe('Case View Attachments tab', () => { ).not.toBeInTheDocument(); }); - it('hides the files accordion when there are no files even if the file type is registered', () => { + it('hides the files accordion when fileStats reports 0 files', () => { useGetCaseFileStatsMock.mockReturnValue({ data: { total: 0 } }); const unifiedAttachmentTypeRegistry = buildRegistry(); @@ -176,7 +176,7 @@ describe('Case View Attachments tab', () => { expect(screen.queryByTestId('case-view-attachment-accordion-file')).not.toBeInTheDocument(); }); - it('renders the files accordion when fileStats reports files', () => { + it('shows the file count from fileStats on the files badge', () => { useGetCaseFileStatsMock.mockReturnValue({ data: { total: 2 } }); const unifiedAttachmentTypeRegistry = buildRegistry(); @@ -189,7 +189,29 @@ describe('Case View Attachments tab', () => { { wrapperProps: { unifiedAttachmentTypeRegistry } } ); - expect(screen.getByTestId('case-view-attachment-accordion-file')).toBeInTheDocument(); + expect(screen.getByTestId('case-view-attachment-badge-file')).toHaveTextContent('2'); + }); + + it('badge counts bulk-added alerts by id length, not by comment/SO count', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + // One alert SO wrapping three alert ids. The badge should read "3", not "1". + const bulkAlertComment = { + ...alertComment, + alertId: ['a-1', 'a-2', 'a-3'], + index: ['i-1', 'i-2', 'i-3'], + }; + const caseWithBulkAlerts: CaseUI = { ...basicCase, comments: [bulkAlertComment] }; + + renderWithTestingProviders( + , + { wrapperProps: { unifiedAttachmentTypeRegistry } } + ); + + expect(screen.getByTestId('case-view-attachment-badge-security.alert')).toHaveTextContent('3'); }); it('renders accordions in alphabetical order by display name', () => { diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx index 07409bd35280e..b72c62a7ab439 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx @@ -16,11 +16,11 @@ import { useCasesFeatures } from '../../../common/use_cases_features'; import { SEARCH_PLACEHOLDER } from '../../actions/translations'; import { CaseViewAttachButton } from './case_view_attach_button'; import { CaseViewTabs } from '../case_view_tabs'; -import { OBSERVABLES_TAB } from '../translations'; import { CaseViewObservables } from './case_view_observables'; import type { OnUpdateFields } from '../types'; import { AttachmentAccordion } from './attachment_accordion'; import { useGetCaseFileStats } from '../../../containers/use_get_case_file_stats'; +import { getAttachmentItemCount } from './helpers'; interface CaseViewAttachmentsProps { caseData: CaseUI; @@ -46,12 +46,13 @@ export const CaseViewAttachments = ({ const counts = new Map(); for (const comment of caseData.comments) { const unifiedType = toUnifiedAttachmentType(comment.type, owner); - counts.set(unifiedType, (counts.get(unifiedType) ?? 0) + 1); + counts.set(unifiedType, (counts.get(unifiedType) ?? 0) + getAttachmentItemCount(comment)); } return counts; }, [caseData.comments, owner]); - // Render one accordion per registered type that has a tab view AND a non-zero count + // Render one accordion per registered type that has a tab view AND a non-zero count. + // file count uses the file client as the source of truth const attachmentSections = useMemo( () => unifiedAttachmentTypeRegistry @@ -59,7 +60,6 @@ export const CaseViewAttachments = ({ .flatMap((type) => { const Children = type.getAttachmentTabViewObject?.()?.children; if (!Children) return []; - // file count rely on file client as source of truth const count = type.id === FILE_ATTACHMENT_TYPE ? fileCount : countsByType.get(type.id) ?? 0; if (count < 1) return []; @@ -103,18 +103,12 @@ export const CaseViewAttachments = ({ ))} {showObservables && ( - - - + )} diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx index 6a1c71716cfa3..e5f6bc3d6d752 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx @@ -9,7 +9,8 @@ import type { CaseUI } from '../../../../common/ui/types'; import { ObservablesTable } from '../../observables/observables_table'; import { useCaseObservables } from '../use_case_observables'; import type { OnUpdateFields } from '../types'; - +import { OBSERVABLES_TAB } from '../../user_actions/translations'; +import { AttachmentAccordion } from './attachment_accordion'; interface CaseViewObservablesProps { caseData: CaseUI; searchTerm?: string; @@ -42,12 +43,18 @@ export const CaseViewObservables = ({ [caseData.settings, onUpdateField] ); + if (searchTerm && observables?.length === 0) { + return null; + } + return ( - + + + ); }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts index 509f70ce056bc..b9426e33bee20 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts @@ -6,10 +6,13 @@ */ import { alertComment, eventComment, basicCase, basicComment } from '../../../containers/mock'; -import { SECURITY_ALERT_ATTACHMENT_TYPE } from '../../../../common/constants/attachments'; +import { + SECURITY_ALERT_ATTACHMENT_TYPE, + SECURITY_EVENT_ATTACHMENT_TYPE, +} from '../../../../common/constants/attachments'; import type { AttachmentUIV2 } from '../../../../common/ui/types'; import { getManualAlertIds } from '../../../../common/utils/attachments/manual_alert_ids'; -import { filterCaseAttachmentsBySearchTerm } from './helpers'; +import { filterCaseAttachmentsBySearchTerm, getAttachmentItemCount } from './helpers'; const comment = { ...alertComment, @@ -43,6 +46,57 @@ const unifiedAlertComment = { }; describe('Case view helpers', () => { + describe('getAttachmentItemCount', () => { + it('counts a legacy alert with a single id as 1', () => { + expect(getAttachmentItemCount(alertComment)).toBe(1); + }); + + it('counts a legacy alert with an array of ids by length', () => { + const bulk = { ...alertComment, alertId: ['a-1', 'a-2', 'a-3'], index: ['i-1', 'i-2', 'i-3'] }; + expect(getAttachmentItemCount(bulk)).toBe(3); + }); + + it('counts a legacy event with a single id as 1', () => { + expect(getAttachmentItemCount(eventComment)).toBe(1); + }); + + it('counts a legacy event with an array of ids by length', () => { + const bulk = { ...eventComment, eventId: ['e-1', 'e-2'], index: ['i-1', 'i-2'] }; + expect(getAttachmentItemCount(bulk)).toBe(2); + }); + + it('counts a unified alert with a single attachmentId as 1', () => { + expect(getAttachmentItemCount(unifiedAlertComment as unknown as AttachmentUIV2)).toBe(1); + }); + + it('counts a unified alert with an array of attachmentIds by length', () => { + const bulk = { ...unifiedAlertComment, attachmentId: ['ua-1', 'ua-2', 'ua-3', 'ua-4'] }; + expect(getAttachmentItemCount(bulk as unknown as AttachmentUIV2)).toBe(4); + }); + + it('counts a unified event with an array of attachmentIds by length', () => { + const unifiedEvent = { + ...unifiedAlertComment, + type: SECURITY_EVENT_ATTACHMENT_TYPE, + attachmentId: ['ue-1', 'ue-2'], + }; + expect(getAttachmentItemCount(unifiedEvent as unknown as AttachmentUIV2)).toBe(2); + }); + + it('counts other unified reference attachments (e.g. files) as the length of attachmentId', () => { + const fileComment = { + ...unifiedAlertComment, + type: 'file', + attachmentId: 'file-so-1', + }; + expect(getAttachmentItemCount(fileComment as unknown as AttachmentUIV2)).toBe(1); + }); + + it('counts a basic user comment as 1', () => { + expect(getAttachmentItemCount(basicComment as unknown as AttachmentUIV2)).toBe(1); + }); + }); + describe('getManualAlertIds', () => { it('returns the alert ids', () => { const result = getManualAlertIds([comment, comment2]); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.ts index 76125a6c2ecaa..8e74cefad86f2 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.ts @@ -16,10 +16,24 @@ import type { } from '../../../../common/ui/types'; import { isLegacyEventAttachment, - isUnifiedEventAttachment, + isUnifiedReferenceAttachmentRequest, isUnifiedAlertAttachment, + isUnifiedEventAttachment, } from '../../../../common/utils/attachments'; +export const getAttachmentItemCount = (comment: AttachmentUIV2): number => { + if (isAlertAttachment(comment)) { + return Array.isArray(comment.alertId) ? comment.alertId.length : 1; + } + if (isLegacyEventAttachment(comment)) { + return Array.isArray(comment.eventId) ? comment.eventId.length : 1; + } + if (isUnifiedReferenceAttachmentRequest(comment)) { + return Array.isArray(comment.attachmentId) ? comment.attachmentId.length : 1; + } + return 1; +}; + const isAlertAttachment = (comment: AttachmentUIV2): comment is AlertAttachmentUI => { return comment.type === AttachmentType.alert && `alertId` in comment; }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx index afe77205ed31b..15946974bc575 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.test.tsx @@ -79,6 +79,27 @@ describe('useCaseAttachmentsTotal', () => { expect(result.current).toBe(4); }); + it('counts bulk-added alerts by id length', () => { + const unifiedAttachmentTypeRegistry = buildRegistry(); + const bulkAlertComment = { + ...alertComment, + alertId: ['a-1', 'a-2', 'a-3'], + index: ['i-1', 'i-2', 'i-3'], + }; + const caseWithBulkAlerts: CaseUI = { ...basicCase, comments: [bulkAlertComment] }; + + const { result } = renderHook(() => useCaseAttachmentsTotal({ caseData: caseWithBulkAlerts }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // 3 alerts (from bulk comment) + 3 files = 6 + expect(result.current).toBe(6); + }); + it('does not double-count file comments — files come from fileStats only', () => { const unifiedAttachmentTypeRegistry = buildRegistry(); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx index 6b0faea1988a5..c161fa7fe47ef 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx @@ -17,6 +17,7 @@ import { useCaseObservables } from './use_case_observables'; import { useCasesFeatures } from '../../common/use_cases_features'; import { toUnifiedAttachmentType } from '../../../common/utils/attachments/migration_utils'; import { FILE_ATTACHMENT_TYPE } from '../../../common/constants'; +import { getAttachmentItemCount } from './components/helpers'; /** * Tab ids that resolve to the consolidated attachments view. Includes the @@ -74,9 +75,9 @@ AttachmentsBadge.displayName = 'AttachmentsBadge'; /** * Computes the total count shown on the top-level "Attachments" tab badge. - * Matches what `CaseViewAttachments` actually renders: one row per attachment - * whose registered type has a tab view (files excluded — counted via - * `fileStatsData`), plus files and (license-permitting) observables. + * Reflects the active search filter so the badge tracks what the user actually + * sees: comments matching a registered type with a tab view, plus files (from + * `fileStatsData`) and — if licensed — observables. */ export const useCaseAttachmentsTotal = ({ caseData, @@ -107,7 +108,7 @@ export const useCaseAttachmentsTotal = ({ let registryTotal = 0; for (const comment of caseData.comments) { if (typesWithTabView.has(toUnifiedAttachmentType(comment.type, owner))) { - registryTotal += 1; + registryTotal += getAttachmentItemCount(comment); } } diff --git a/x-pack/platform/test/functional/services/cases/files.ts b/x-pack/platform/test/functional/services/cases/files.ts index e13df8bc1cccc..a8ccec96e3d83 100644 --- a/x-pack/platform/test/functional/services/cases/files.ts +++ b/x-pack/platform/test/functional/services/cases/files.ts @@ -71,7 +71,10 @@ export function CasesFilesTableServiceProvider({ getService, getPageObject }: Ft }, async emptyOrFail() { - await testSubjects.existOrFail('cases-files-table-empty'); + // The files accordion only renders when the case has at least one file + // (or one file matching the active search), so "no files" now means the + // accordion is missing rather than the table showing its empty state. + await testSubjects.missingOrFail('case-view-attachment-accordion-file'); }, async getFileByIndex(index: number) { From 56c999261f0ad3bfcf2ed7c0688443381cf53fbc Mon Sep 17 00:00:00 2001 From: christineweng Date: Wed, 27 May 2026 20:17:54 -0500 Subject: [PATCH 5/8] add empty state --- .../components/case_view_attachments.test.tsx | 55 ++++++++++++++++++ .../components/case_view_attachments.tsx | 57 +++++++++++++------ .../components/case_view_observables.test.tsx | 28 ++++++++- .../components/case_view_observables.tsx | 24 ++++---- .../case_view/components/helpers.test.ts | 6 +- .../components/case_view/translations.ts | 13 +++++ 6 files changed, 151 insertions(+), 32 deletions(-) diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx index 93f9f3dd71af0..b92e39a3e4795 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx @@ -214,6 +214,61 @@ describe('Case View Attachments tab', () => { expect(screen.getByTestId('case-view-attachment-badge-security.alert')).toHaveTextContent('3'); }); + it('renders a "no results found" empty state when searching and nothing matches', () => { + useGetCaseFileStatsMock.mockReturnValue({ data: { total: 0 } }); + const unifiedAttachmentTypeRegistry = buildRegistry(); + + renderWithTestingProviders( + , + { wrapperProps: { unifiedAttachmentTypeRegistry, license: basicLicense } } + ); + + const emptyPrompt = screen.getByTestId('case-view-attachments-no-search-results'); + expect(emptyPrompt).toBeInTheDocument(); + expect(emptyPrompt).toHaveTextContent('No results found'); + expect(emptyPrompt).toHaveTextContent('foobar'); + expect(screen.queryByTestId('case-view-attachment-accordion-file')).not.toBeInTheDocument(); + }); + + it('does not render the empty state when there is no search term, even if everything is empty', () => { + useGetCaseFileStatsMock.mockReturnValue({ data: { total: 0 } }); + const unifiedAttachmentTypeRegistry = buildRegistry(); + + renderWithTestingProviders( + , + { wrapperProps: { unifiedAttachmentTypeRegistry, license: basicLicense } } + ); + + expect(screen.queryByTestId('case-view-attachments-no-search-results')).not.toBeInTheDocument(); + }); + + it('does not render the empty state when search matches at least one section', () => { + useGetCaseFileStatsMock.mockReturnValue({ data: { total: 1 } }); + const unifiedAttachmentTypeRegistry = buildRegistry(); + + renderWithTestingProviders( + , + { wrapperProps: { unifiedAttachmentTypeRegistry, license: basicLicense } } + ); + + expect(screen.queryByTestId('case-view-attachments-no-search-results')).not.toBeInTheDocument(); + expect(screen.getByTestId('case-view-attachment-accordion-file')).toBeInTheDocument(); + }); + it('renders accordions in alphabetical order by display name', () => { const unifiedAttachmentTypeRegistry = buildRegistry(); // 2 alerts + 1 event so all three registry-driven accordions render diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx index b72c62a7ab439..5cc3461f16407 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFieldSearch, EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiFieldSearch, EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import React, { useMemo } from 'react'; import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import type { CaseUI } from '../../../../common'; @@ -17,10 +17,12 @@ import { SEARCH_PLACEHOLDER } from '../../actions/translations'; import { CaseViewAttachButton } from './case_view_attach_button'; import { CaseViewTabs } from '../case_view_tabs'; import { CaseViewObservables } from './case_view_observables'; +import { useCaseObservables } from '../use_case_observables'; import type { OnUpdateFields } from '../types'; import { AttachmentAccordion } from './attachment_accordion'; import { useGetCaseFileStats } from '../../../containers/use_get_case_file_stats'; import { getAttachmentItemCount } from './helpers'; +import { NO_SEARCH_RESULTS_TITLE, NO_SEARCH_RESULTS_BODY } from '../translations'; interface CaseViewAttachmentsProps { caseData: CaseUI; @@ -72,6 +74,18 @@ export const CaseViewAttachments = ({ // Observables stays hardcoded: there's no other entry point to add observables // to a case, so the accordion must be visible even when the count is 0. const showObservables = observablesAuthorized && isObservablesFeatureEnabled; + const { observables: filteredObservables, isLoading: isLoadingObservables } = useCaseObservables( + caseData, + searchTerm + ); + + const showNoResults = useMemo( + () => + Boolean(searchTerm) && + attachmentSections.length === 0 && + (!showObservables || filteredObservables.length === 0), + [searchTerm, attachmentSections.length, showObservables, filteredObservables.length] + ); return ( <> @@ -96,21 +110,32 @@ export const CaseViewAttachments = ({ - - {attachmentSections.map(({ id, displayName, count, Children }) => ( - - - - ))} - {showObservables && ( - - )} - + {showNoResults ? ( + {NO_SEARCH_RESULTS_TITLE}

} + body={

{NO_SEARCH_RESULTS_BODY(searchTerm ?? '')}

} + /> + ) : ( + + {attachmentSections.map(({ id, displayName, count, Children }) => ( + + + + ))} + {showObservables && ( + + )} + + )}
); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx index 288e1f422011d..132a23f17a4c2 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.test.tsx @@ -19,7 +19,12 @@ describe('Case View Page observables tab', () => { it('should render the utility bar for the observables table', async () => { renderWithTestingProviders( - + ); expect((await screen.findAllByTestId('cases-observables-add')).length).toBe(2); @@ -27,9 +32,28 @@ describe('Case View Page observables tab', () => { it('should render the observable table', async () => { renderWithTestingProviders( - + ); expect(await screen.findByTestId('cases-observables-table')).toBeInTheDocument(); }); + + it('returns null when searching and no observables match', () => { + const { container } = renderWithTestingProviders( + + ); + + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx index e5f6bc3d6d752..4fa7c1c734dad 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_observables.tsx @@ -5,14 +5,15 @@ * 2.0. */ import React, { useCallback, useMemo } from 'react'; -import type { CaseUI } from '../../../../common/ui/types'; +import type { CaseUI, ObservableUI } from '../../../../common/ui/types'; import { ObservablesTable } from '../../observables/observables_table'; -import { useCaseObservables } from '../use_case_observables'; import type { OnUpdateFields } from '../types'; import { OBSERVABLES_TAB } from '../../user_actions/translations'; import { AttachmentAccordion } from './attachment_accordion'; + interface CaseViewObservablesProps { caseData: CaseUI; + observables: ObservableUI[]; searchTerm?: string; isLoading: boolean; onUpdateField: (args: OnUpdateFields) => void; @@ -20,18 +21,15 @@ interface CaseViewObservablesProps { export const CaseViewObservables = ({ caseData, + observables, searchTerm, isLoading, onUpdateField, }: CaseViewObservablesProps) => { - const { observables, isLoading: isLoadingObservables } = useCaseObservables(caseData, searchTerm); - - const caseDataWithFilteredObservables: CaseUI = useMemo(() => { - return { - ...caseData, - observables, - }; - }, [caseData, observables]); + const caseDataWithFilteredObservables: CaseUI = useMemo( + () => ({ ...caseData, observables }), + [caseData, observables] + ); const onExtractObservablesChanged = useCallback( (isOn: boolean) => { @@ -43,15 +41,15 @@ export const CaseViewObservables = ({ [caseData.settings, onUpdateField] ); - if (searchTerm && observables?.length === 0) { + if (searchTerm && observables.length === 0) { return null; } return ( - + diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts index b9426e33bee20..20f5f9a01071d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/helpers.test.ts @@ -52,7 +52,11 @@ describe('Case view helpers', () => { }); it('counts a legacy alert with an array of ids by length', () => { - const bulk = { ...alertComment, alertId: ['a-1', 'a-2', 'a-3'], index: ['i-1', 'i-2', 'i-3'] }; + const bulk = { + ...alertComment, + alertId: ['a-1', 'a-2', 'a-3'], + index: ['i-1', 'i-2', 'i-3'], + }; expect(getAttachmentItemCount(bulk)).toBe(3); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts index 242c19fd7ec45..fb4966a2613a3 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts @@ -281,3 +281,16 @@ export const ERROR_CHANGING_TEMPLATE = i18n.translate( defaultMessage: 'Error changing template', } ); + +export const NO_SEARCH_RESULTS_TITLE = i18n.translate( + 'xpack.cases.caseView.attachments.noSearchResults.title', + { + defaultMessage: 'No results found', + } +); + +export const NO_SEARCH_RESULTS_BODY = (searchTerm: string) => + i18n.translate('xpack.cases.caseView.attachments.noSearchResults.body', { + values: { searchTerm }, + defaultMessage: 'No attachments match "{searchTerm}".', + }); From 75ff32b60b3df88257f6ddabf6b914a7200147ba Mon Sep 17 00:00:00 2001 From: christineweng Date: Thu, 28 May 2026 12:45:31 -0500 Subject: [PATCH 6/8] update empty state --- ...on_product_no_results_magnifying_glass.svg | 1 + .../components/case_view_attachments.test.tsx | 4 +-- .../components/case_view_attachments.tsx | 29 +++++++++++++++---- .../components/case_view/translations.ts | 13 +++++---- 4 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 x-pack/platform/plugins/shared/cases/public/assets/illustration_product_no_results_magnifying_glass.svg diff --git a/x-pack/platform/plugins/shared/cases/public/assets/illustration_product_no_results_magnifying_glass.svg b/x-pack/platform/plugins/shared/cases/public/assets/illustration_product_no_results_magnifying_glass.svg new file mode 100644 index 0000000000000..dbf5c7ef03dca --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/assets/illustration_product_no_results_magnifying_glass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx index b92e39a3e4795..e00251d1a54f2 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx @@ -230,8 +230,8 @@ describe('Case View Attachments tab', () => { const emptyPrompt = screen.getByTestId('case-view-attachments-no-search-results'); expect(emptyPrompt).toBeInTheDocument(); - expect(emptyPrompt).toHaveTextContent('No results found'); - expect(emptyPrompt).toHaveTextContent('foobar'); + expect(emptyPrompt).toHaveTextContent('No results match your search criteria'); + expect(emptyPrompt).toHaveTextContent('Try modifying your search.'); expect(screen.queryByTestId('case-view-attachment-accordion-file')).not.toBeInTheDocument(); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx index 5cc3461f16407..3712c416c6484 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.tsx @@ -5,8 +5,17 @@ * 2.0. */ -import { EuiEmptyPrompt, EuiFieldSearch, EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +import { + EuiEmptyPrompt, + EuiFieldSearch, + EuiFlexItem, + EuiFlexGroup, + EuiImage, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; import React, { useMemo } from 'react'; +import noResultsIllustration from '../../../assets/illustration_product_no_results_magnifying_glass.svg'; import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import type { CaseUI } from '../../../../common'; import { FILE_ATTACHMENT_TYPE } from '../../../../common/constants'; @@ -37,6 +46,7 @@ export const CaseViewAttachments = ({ searchTerm, onUpdateField, }: CaseViewAttachmentsProps) => { + const { euiTheme } = useEuiTheme(); const { unifiedAttachmentTypeRegistry } = useCasesContext(); const { observablesAuthorized, isObservablesFeatureEnabled } = useCasesFeatures(); const { data: fileStats } = useGetCaseFileStats({ caseId: caseData.id, searchTerm }); @@ -113,10 +123,19 @@ export const CaseViewAttachments = ({ {showNoResults ? ( {NO_SEARCH_RESULTS_TITLE}} - body={

{NO_SEARCH_RESULTS_BODY(searchTerm ?? '')}

} + layout="horizontal" + color="transparent" + css={{ paddingBlockStart: euiTheme.size.xxl }} + icon={ + + } + title={

{NO_SEARCH_RESULTS_TITLE}

} + body={

{NO_SEARCH_RESULTS_BODY}

} /> ) : ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts index fb4966a2613a3..235e369de6593 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/translations.ts @@ -285,12 +285,13 @@ export const ERROR_CHANGING_TEMPLATE = i18n.translate( export const NO_SEARCH_RESULTS_TITLE = i18n.translate( 'xpack.cases.caseView.attachments.noSearchResults.title', { - defaultMessage: 'No results found', + defaultMessage: 'No results match your search criteria', } ); -export const NO_SEARCH_RESULTS_BODY = (searchTerm: string) => - i18n.translate('xpack.cases.caseView.attachments.noSearchResults.body', { - values: { searchTerm }, - defaultMessage: 'No attachments match "{searchTerm}".', - }); +export const NO_SEARCH_RESULTS_BODY = i18n.translate( + 'xpack.cases.caseView.attachments.noSearchResults.body', + { + defaultMessage: 'Try modifying your search.', + } +); From 932acf8972869d12bfc8df84d6d98ca10c9e1aca Mon Sep 17 00:00:00 2001 From: christineweng Date: Fri, 29 May 2026 10:41:08 -0500 Subject: [PATCH 7/8] add update button --- .../components/case_view_attachments.test.tsx | 38 +++++++++++++++++++ .../components/case_view_attachments.tsx | 29 +++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx index e00251d1a54f2..70723ee01ebca 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attachments.test.tsx @@ -111,6 +111,44 @@ describe('Case View Attachments tab', () => { }); }); + it('shows the update button as "needs update" when input differs from the applied search', async () => { + renderWithTestingProviders( + + ); + + // When the input matches the applied search term, the button renders the + // "Refresh" label. Once the input diverges, EuiSuperUpdateButton switches + // to a filled success "Update" label. + expect(screen.getByTestId('cases-attachments-update-button')).toHaveTextContent('Refresh'); + + await userEvent.type(screen.getByTestId('cases-files-search'), 'foo'); + + expect(screen.getByTestId('cases-attachments-update-button')).toHaveTextContent('Update'); + }); + + it('calls onSearch with the typed value when clicking the update button while dirty', async () => { + renderWithTestingProviders( + + ); + + await userEvent.type(screen.getByTestId('cases-files-search'), 'bar'); + await userEvent.click(screen.getByTestId('cases-attachments-update-button')); + + await waitFor(() => { + expect(onSearchMock).toHaveBeenCalledWith('bar'); + }); + }); + it('does not render an observables accordion when license is basic', async () => { renderWithTestingProviders( { + setInputValue(searchTerm ?? ''); + }, [searchTerm]); + + const refreshCaseView = useRefreshCaseViewPage(); + const isDirty = inputValue !== (searchTerm ?? ''); + const onClickUpdate = useCallback(() => { + if (isDirty) { + onSearch(inputValue); + } else { + refreshCaseView(); + } + }, [isDirty, inputValue, onSearch, refreshCaseView]); + return ( <> @@ -110,11 +127,21 @@ export const CaseViewAttachments = ({ setInputValue(e.target.value)} onSearch={onSearch} data-test-subj="cases-files-search" fullWidth /> + + + From 0085312b3524cc8b73806dd0fe034083852056b6 Mon Sep 17 00:00:00 2001 From: christineweng Date: Fri, 29 May 2026 14:04:01 -0500 Subject: [PATCH 8/8] add test --- .../functional_with_es_ssl/apps/cases/group1/view_case.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 7e660f6a5537f..3847047532fe1 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -915,6 +915,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('adds a file to the case', async () => { await cases.casesFilesTable.addFile(require.resolve('./elastic_logo.png')); + // The files accordion only renders when the case has at least one + // file, so confirm it appears after the upload. + await testSubjects.existOrFail('case-view-attachment-accordion-file'); + // make sure the uploaded file is displayed on the table await find.byButtonText('elastic_logo.png'); });