diff --git a/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx b/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx
index 498a4a93b5fe4..45e74312e1e55 100644
--- a/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx
+++ b/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx
@@ -19,7 +19,7 @@ import { context } from './context';
/**
* An object representing an uploaded file
*/
-interface UploadedFile {
+export interface UploadedFile {
/**
* The ID that was generated for the uploaded file
*/
diff --git a/x-pack/plugins/cases/common/constants/mime_types.ts b/x-pack/plugins/cases/common/constants/mime_types.ts
index 9f1f455513dab..c35e5ef674c81 100644
--- a/x-pack/plugins/cases/common/constants/mime_types.ts
+++ b/x-pack/plugins/cases/common/constants/mime_types.ts
@@ -8,7 +8,7 @@
/**
* These were retrieved from https://www.iana.org/assignments/media-types/media-types.xhtml#image
*/
-const imageMimeTypes = [
+export const imageMimeTypes = [
'image/aces',
'image/apng',
'image/avci',
@@ -87,9 +87,9 @@ const imageMimeTypes = [
'image/wmf',
];
-const textMimeTypes = ['text/plain', 'text/csv', 'text/json', 'application/json'];
+export const textMimeTypes = ['text/plain', 'text/csv', 'text/json', 'application/json'];
-const compressionMimeTypes = [
+export const compressionMimeTypes = [
'application/zip',
'application/gzip',
'application/x-bzip',
@@ -98,7 +98,7 @@ const compressionMimeTypes = [
'application/x-tar',
];
-const pdfMimeTypes = ['application/pdf'];
+export const pdfMimeTypes = ['application/pdf'];
export const ALLOWED_MIME_TYPES = [
...imageMimeTypes,
diff --git a/x-pack/plugins/cases/common/types.ts b/x-pack/plugins/cases/common/types.ts
index 3ff14b0905110..32d6b34b11c16 100644
--- a/x-pack/plugins/cases/common/types.ts
+++ b/x-pack/plugins/cases/common/types.ts
@@ -24,4 +24,5 @@ export type SnakeToCamelCase = T extends Record
export enum CASE_VIEW_PAGE_TABS {
ALERTS = 'alerts',
ACTIVITY = 'activity',
+ FILES = 'files',
}
diff --git a/x-pack/plugins/cases/public/application.tsx b/x-pack/plugins/cases/public/application.tsx
index bac423a9f8292..742f254472160 100644
--- a/x-pack/plugins/cases/public/application.tsx
+++ b/x-pack/plugins/cases/public/application.tsx
@@ -9,19 +9,21 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { Router } from 'react-router-dom';
-import { I18nProvider } from '@kbn/i18n-react';
import { EuiErrorBoundary } from '@elastic/eui';
-
+import { I18nProvider } from '@kbn/i18n-react';
+import { EuiThemeProvider as StyledComponentsThemeProvider } from '@kbn/kibana-react-plugin/common';
import {
KibanaContextProvider,
KibanaThemeProvider,
useUiSetting$,
} from '@kbn/kibana-react-plugin/public';
-import { EuiThemeProvider as StyledComponentsThemeProvider } from '@kbn/kibana-react-plugin/common';
-import type { RenderAppProps } from './types';
-import { CasesApp } from './components/app';
+
+import type { ScopedFilesClient } from '@kbn/files-plugin/public';
import type { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
import type { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry';
+import type { RenderAppProps } from './types';
+
+import { CasesApp } from './components/app';
export const renderApp = (deps: RenderAppProps) => {
const { mountParams } = deps;
@@ -37,10 +39,15 @@ export const renderApp = (deps: RenderAppProps) => {
interface CasesAppWithContextProps {
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
+ getFilesClient: (scope: string) => ScopedFilesClient;
}
const CasesAppWithContext: React.FC = React.memo(
- ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry }) => {
+ ({
+ externalReferenceAttachmentTypeRegistry,
+ persistableStateAttachmentTypeRegistry,
+ getFilesClient,
+ }) => {
const [darkMode] = useUiSetting$('theme:darkMode');
return (
@@ -48,6 +55,7 @@ const CasesAppWithContext: React.FC = React.memo(
);
@@ -78,6 +86,7 @@ export const App: React.FC<{ deps: RenderAppProps }> = ({ deps }) => {
deps.externalReferenceAttachmentTypeRegistry
}
persistableStateAttachmentTypeRegistry={deps.persistableStateAttachmentTypeRegistry}
+ getFilesClient={pluginsStart.files.filesClientFactory.asScoped}
/>
diff --git a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx
index b0807b0509135..fc85e84639baa 100644
--- a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx
+++ b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx
@@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context';
type GetAllCasesSelectorModalPropsInternal = AllCasesSelectorModalProps & CasesContextProps;
export type GetAllCasesSelectorModalProps = Omit<
GetAllCasesSelectorModalPropsInternal,
- 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry'
+ | 'externalReferenceAttachmentTypeRegistry'
+ | 'persistableStateAttachmentTypeRegistry'
+ | 'getFilesClient'
>;
const AllCasesSelectorModalLazy: React.FC = lazy(
@@ -23,6 +25,7 @@ const AllCasesSelectorModalLazy: React.FC = lazy(
export const getAllCasesSelectorModalLazy = ({
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
hiddenStatuses,
@@ -33,6 +36,7 @@ export const getAllCasesSelectorModalLazy = ({
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
}}
diff --git a/x-pack/plugins/cases/public/client/ui/get_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_cases.tsx
index 45c9f30b984d2..36556523fc3a3 100644
--- a/x-pack/plugins/cases/public/client/ui/get_cases.tsx
+++ b/x-pack/plugins/cases/public/client/ui/get_cases.tsx
@@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context';
type GetCasesPropsInternal = CasesProps & CasesContextProps;
export type GetCasesProps = Omit<
GetCasesPropsInternal,
- 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry'
+ | 'externalReferenceAttachmentTypeRegistry'
+ | 'persistableStateAttachmentTypeRegistry'
+ | 'getFilesClient'
>;
const CasesRoutesLazy: React.FC = lazy(() => import('../../components/app/routes'));
@@ -22,6 +24,7 @@ const CasesRoutesLazy: React.FC = lazy(() => import('../../component
export const getCasesLazy = ({
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
basePath,
@@ -39,6 +42,7 @@ export const getCasesLazy = ({
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
basePath,
diff --git a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx
index 77e6ca3c87e24..9db49ef9776ba 100644
--- a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx
+++ b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx
@@ -13,7 +13,9 @@ import type { CasesContextProps } from '../../components/cases_context';
export type GetCasesContextPropsInternal = CasesContextProps;
export type GetCasesContextProps = Omit<
CasesContextProps,
- 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry'
+ | 'externalReferenceAttachmentTypeRegistry'
+ | 'persistableStateAttachmentTypeRegistry'
+ | 'getFilesClient'
>;
const CasesProviderLazy: React.FC<{ value: GetCasesContextPropsInternal }> = lazy(
@@ -28,6 +30,7 @@ const CasesProviderLazyWrapper = ({
features,
children,
releasePhase,
+ getFilesClient,
}: GetCasesContextPropsInternal & { children: ReactNode }) => {
return (
}>
@@ -39,6 +42,7 @@ const CasesProviderLazyWrapper = ({
permissions,
features,
releasePhase,
+ getFilesClient,
}}
>
{children}
@@ -52,9 +56,12 @@ CasesProviderLazyWrapper.displayName = 'CasesProviderLazyWrapper';
export const getCasesContextLazy = ({
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
}: Pick<
GetCasesContextPropsInternal,
- 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry'
+ | 'externalReferenceAttachmentTypeRegistry'
+ | 'persistableStateAttachmentTypeRegistry'
+ | 'getFilesClient'
>): (() => React.FC) => {
const CasesProviderLazyWrapperWithRegistry: React.FC = ({
children,
@@ -64,6 +71,7 @@ export const getCasesContextLazy = ({
{...props}
externalReferenceAttachmentTypeRegistry={externalReferenceAttachmentTypeRegistry}
persistableStateAttachmentTypeRegistry={persistableStateAttachmentTypeRegistry}
+ getFilesClient={getFilesClient}
>
{children}
diff --git a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx
index af932b53e1dde..e52a14033a614 100644
--- a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx
+++ b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx
@@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context';
type GetCreateCaseFlyoutPropsInternal = CreateCaseFlyoutProps & CasesContextProps;
export type GetCreateCaseFlyoutProps = Omit<
GetCreateCaseFlyoutPropsInternal,
- 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry'
+ | 'externalReferenceAttachmentTypeRegistry'
+ | 'persistableStateAttachmentTypeRegistry'
+ | 'getFilesClient'
>;
export const CreateCaseFlyoutLazy: React.FC = lazy(
@@ -23,6 +25,7 @@ export const CreateCaseFlyoutLazy: React.FC = lazy(
export const getCreateCaseFlyoutLazy = ({
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
features,
@@ -35,6 +38,7 @@ export const getCreateCaseFlyoutLazy = ({
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
features,
diff --git a/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx
index a047c106246da..7c41cc3842bf7 100644
--- a/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx
+++ b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx
@@ -14,7 +14,9 @@ import type { RecentCasesProps } from '../../components/recent_cases';
type GetRecentCasesPropsInternal = RecentCasesProps & CasesContextProps;
export type GetRecentCasesProps = Omit<
GetRecentCasesPropsInternal,
- 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry'
+ | 'externalReferenceAttachmentTypeRegistry'
+ | 'persistableStateAttachmentTypeRegistry'
+ | 'getFilesClient'
>;
const RecentCasesLazy: React.FC = lazy(
@@ -23,6 +25,7 @@ const RecentCasesLazy: React.FC = lazy(
export const getRecentCasesLazy = ({
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
maxCasesToShow,
@@ -31,6 +34,7 @@ export const getRecentCasesLazy = ({
value={{
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner,
permissions,
}}
diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx
index 2a5a75bf7a789..7728ecb54d766 100644
--- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx
+++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx
@@ -9,22 +9,29 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
+import { ThemeProvider } from 'styled-components';
+
+import type { RenderOptions, RenderResult } from '@testing-library/react';
+import type { ILicense } from '@kbn/licensing-plugin/public';
+import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
+import type { ScopedFilesClient } from '@kbn/files-plugin/public';
+
import { euiDarkVars } from '@kbn/ui-theme';
import { I18nProvider } from '@kbn/i18n-react';
-import { ThemeProvider } from 'styled-components';
+import { createMockFilesClient } from '@kbn/shared-ux-file-mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import type { RenderOptions, RenderResult } from '@testing-library/react';
import { render as reactRender } from '@testing-library/react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
-import type { ILicense } from '@kbn/licensing-plugin/public';
-import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
-import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
+import { FilesContext } from '@kbn/shared-ux-file-context';
+
import type { CasesFeatures, CasesPermissions } from '../../../common/ui/types';
-import { CasesProvider } from '../../components/cases_context';
-import { createStartServicesMock } from '../lib/kibana/kibana_react.mock';
import type { StartServices } from '../../types';
import type { ReleasePhase } from '../../components/types';
+
+import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
+import { CasesProvider } from '../../components/cases_context';
+import { createStartServicesMock } from '../lib/kibana/kibana_react.mock';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry';
import { allCasesPermissions } from './permissions';
@@ -37,21 +44,36 @@ interface TestProviderProps {
releasePhase?: ReleasePhase;
externalReferenceAttachmentTypeRegistry?: ExternalReferenceAttachmentTypeRegistry;
persistableStateAttachmentTypeRegistry?: PersistableStateAttachmentTypeRegistry;
+ getFilesClient?: (scope: string) => ScopedFilesClient;
license?: ILicense;
}
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
window.scrollTo = jest.fn();
+export const mockedFilesClient = createMockFilesClient() as unknown as ScopedFilesClient;
+
+// @ts-ignore
+mockedFilesClient.getFileKind.mockImplementation(() => ({
+ id: 'test',
+ maxSizeBytes: 10000,
+ http: {},
+}));
+
+const mockGetFilesClient = () => mockedFilesClient;
+
+export const mockedTestProvidersOwner = [SECURITY_SOLUTION_OWNER];
+
/** A utility for wrapping children in the providers required to run most tests */
const TestProvidersComponent: React.FC = ({
children,
features,
- owner = [SECURITY_SOLUTION_OWNER],
+ owner = mockedTestProvidersOwner,
permissions = allCasesPermissions(),
releasePhase = 'ga',
externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(),
persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(),
+ getFilesClient = mockGetFilesClient,
license,
}) => {
const queryClient = new QueryClient({
@@ -82,9 +104,10 @@ const TestProvidersComponent: React.FC = ({
features,
owner,
permissions,
+ getFilesClient,
}}
>
- {children}
+ {children}
@@ -125,11 +148,12 @@ export const testQueryClient = new QueryClient({
export const createAppMockRenderer = ({
features,
- owner = [SECURITY_SOLUTION_OWNER],
+ owner = mockedTestProvidersOwner,
permissions = allCasesPermissions(),
releasePhase = 'ga',
externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(),
persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(),
+ getFilesClient = mockGetFilesClient,
license,
}: Omit = {}): AppMockRenderer => {
const services = createStartServicesMock({ license });
@@ -161,6 +185,7 @@ export const createAppMockRenderer = ({
owner,
permissions,
releasePhase,
+ getFilesClient,
}}
>
{children}
diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx
index 6c33c86d29d51..7191767f780dd 100644
--- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx
+++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx
@@ -25,6 +25,7 @@ const useKibanaMock = useKibana as jest.Mocked;
describe('Use cases toast hook', () => {
const successMock = jest.fn();
const errorMock = jest.fn();
+ const dangerMock = jest.fn();
const getUrlForApp = jest.fn().mockReturnValue(`/app/cases/${mockCase.id}`);
const navigateToUrl = jest.fn();
@@ -54,6 +55,7 @@ describe('Use cases toast hook', () => {
return {
addSuccess: successMock,
addError: errorMock,
+ addDanger: dangerMock,
};
});
@@ -352,4 +354,21 @@ describe('Use cases toast hook', () => {
});
});
});
+
+ describe('showDangerToast', () => {
+ it('should show a danger toast', () => {
+ const { result } = renderHook(
+ () => {
+ return useCasesToast();
+ },
+ { wrapper: TestProviders }
+ );
+
+ result.current.showDangerToast('my danger toast');
+
+ expect(dangerMock).toHaveBeenCalledWith({
+ title: 'my danger toast',
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx
index 3d90005546464..a003226688011 100644
--- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx
+++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx
@@ -169,6 +169,9 @@ export const useCasesToast = () => {
showSuccessToast: (title: string) => {
toasts.addSuccess({ title, className: 'eui-textBreakWord' });
},
+ showDangerToast: (title: string) => {
+ toasts.addDanger({ title });
+ },
};
};
diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx
index 42ef9b658fea7..f53e7edf9356a 100644
--- a/x-pack/plugins/cases/public/components/app/index.tsx
+++ b/x-pack/plugins/cases/public/components/app/index.tsx
@@ -6,12 +6,15 @@
*/
import React from 'react';
-import { APP_OWNER } from '../../../common/constants';
+
+import type { ScopedFilesClient } from '@kbn/files-plugin/public';
+
import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry';
+
+import { APP_OWNER } from '../../../common/constants';
import { getCasesLazy } from '../../client/ui/get_cases';
import { useApplicationCapabilities } from '../../common/lib/kibana';
-
import { Wrapper } from '../wrappers';
import type { CasesRoutesProps } from './types';
@@ -20,11 +23,13 @@ export type CasesProps = CasesRoutesProps;
interface CasesAppProps {
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
+ getFilesClient: (scope: string) => ScopedFilesClient;
}
const CasesAppComponent: React.FC = ({
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
}) => {
const userCapabilities = useApplicationCapabilities();
@@ -33,6 +38,7 @@ const CasesAppComponent: React.FC = ({
{getCasesLazy({
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
+ getFilesClient,
owner: [APP_OWNER],
useFetchAlertData: () => [false, {}],
permissions: userCapabilities.generalCases,
diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx
index 6dadd451f23b2..dc0e043ea9e17 100644
--- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx
@@ -485,8 +485,9 @@ describe('CaseViewPage', () => {
it('renders tabs correctly', async () => {
const result = appMockRenderer.render();
await act(async () => {
- expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy();
expect(result.getByTestId('case-view-tab-title-activity')).toBeTruthy();
+ expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy();
+ expect(result.getByTestId('case-view-tab-title-files')).toBeTruthy();
});
});
diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx
index a26793e501897..55245de4b22b2 100644
--- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx
@@ -18,6 +18,7 @@ import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs';
import { WhitePageWrapperNoBorder } from '../wrappers';
import { CaseViewActivity } from './components/case_view_activity';
import { CaseViewAlerts } from './components/case_view_alerts';
+import { CaseViewFiles } from './components/case_view_files';
import { CaseViewMetrics } from './metrics';
import type { CaseViewPageProps } from './types';
import { useRefreshCaseViewPage } from './use_on_refresh_case_view_page';
@@ -140,6 +141,7 @@ export const CaseViewPage = React.memo(
{activeTabId === CASE_VIEW_PAGE_TABS.ALERTS && features.alerts.enabled && (
)}
+ {activeTabId === CASE_VIEW_PAGE_TABS.FILES && }
{timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null}
diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx
index a3da7d90267cf..2494602c58503 100644
--- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx
@@ -62,6 +62,7 @@ describe('CaseViewTabs', () => {
expect(await screen.findByTestId('case-view-tab-title-activity')).toBeInTheDocument();
expect(await screen.findByTestId('case-view-tab-title-alerts')).toBeInTheDocument();
+ expect(await screen.findByTestId('case-view-tab-title-files')).toBeInTheDocument();
});
it('renders the activity tab by default', async () => {
@@ -82,6 +83,15 @@ describe('CaseViewTabs', () => {
);
});
+ it('shows the files tab as active', async () => {
+ appMockRenderer.render();
+
+ expect(await screen.findByTestId('case-view-tab-title-files')).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ });
+
it('navigates to the activity tab when the activity tab is clicked', async () => {
const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView;
appMockRenderer.render();
@@ -109,4 +119,18 @@ describe('CaseViewTabs', () => {
});
});
});
+
+ it('navigates to the files tab when the files tab is clicked', async () => {
+ const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView;
+ appMockRenderer.render();
+
+ userEvent.click(await screen.findByTestId('case-view-tab-title-files'));
+
+ await waitFor(() => {
+ expect(navigateToCaseViewMock).toHaveBeenCalledWith({
+ detailName: caseData.id,
+ tabId: CASE_VIEW_PAGE_TABS.FILES,
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx
index 746311051f147..ebfaec30531b2 100644
--- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx
@@ -12,7 +12,7 @@ import { CASE_VIEW_PAGE_TABS } from '../../../common/types';
import { useCaseViewNavigation } from '../../common/navigation';
import { useCasesContext } from '../cases_context/use_cases_context';
import { EXPERIMENTAL_DESC, EXPERIMENTAL_LABEL } from '../header_page/translations';
-import { ACTIVITY_TAB, ALERTS_TAB } from './translations';
+import { ACTIVITY_TAB, ALERTS_TAB, FILES_TAB } from './translations';
import type { Case } from '../../../common';
const ExperimentalBadge = styled(EuiBetaBadge)`
@@ -56,6 +56,10 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab
},
]
: []),
+ {
+ id: CASE_VIEW_PAGE_TABS.FILES,
+ name: FILES_TAB,
+ },
],
[features.alerts.enabled, features.alerts.isExperimental]
);
diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx
new file mode 100644
index 0000000000000..aceccca5b44fa
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx
@@ -0,0 +1,73 @@
+/*
+ * 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, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import type { Case } from '../../../../common';
+import type { AppMockRenderer } from '../../../common/mock';
+
+import { createAppMockRenderer } from '../../../common/mock';
+import { alertCommentWithIndices, basicCase } from '../../../containers/mock';
+import { useGetCaseFiles } from '../../../containers/use_get_case_files';
+import { CaseViewFiles, DEFAULT_CASE_FILES_FILTERING_OPTIONS } from './case_view_files';
+
+jest.mock('../../../containers/use_get_case_files');
+
+const useGetCaseFilesMock = useGetCaseFiles as jest.Mock;
+
+const caseData: Case = {
+ ...basicCase,
+ comments: [...basicCase.comments, alertCommentWithIndices],
+};
+
+describe('Case View Page files tab', () => {
+ let appMockRender: AppMockRenderer;
+
+ useGetCaseFilesMock.mockReturnValue({
+ data: { files: [], total: 11 },
+ isLoading: false,
+ });
+
+ beforeEach(() => {
+ appMockRender = createAppMockRenderer();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render the utility bar for the files table', async () => {
+ appMockRender.render();
+
+ expect((await screen.findAllByTestId('cases-files-add')).length).toBe(2);
+ expect(await screen.findByTestId('cases-files-search')).toBeInTheDocument();
+ });
+
+ it('should render the files table', async () => {
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument();
+ });
+
+ it('clicking table pagination triggers calls to useGetCaseFiles', async () => {
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument();
+
+ userEvent.click(await screen.findByTestId('pagination-button-next'));
+
+ await waitFor(() =>
+ expect(useGetCaseFilesMock).toHaveBeenCalledWith({
+ caseId: basicCase.id,
+ page: DEFAULT_CASE_FILES_FILTERING_OPTIONS.page + 1,
+ perPage: DEFAULT_CASE_FILES_FILTERING_OPTIONS.perPage,
+ })
+ );
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx
new file mode 100644
index 0000000000000..54693acfa2390
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx
@@ -0,0 +1,104 @@
+/*
+ * 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 { isEqual } from 'lodash/fp';
+import React, { useCallback, useMemo, useState } from 'react';
+
+import type { Criteria } from '@elastic/eui';
+import type { FileJSON } from '@kbn/shared-ux-file-types';
+
+import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
+
+import type { Case } from '../../../../common/ui/types';
+import type { CaseFilesFilteringOptions } from '../../../containers/use_get_case_files';
+
+import { CASE_VIEW_PAGE_TABS } from '../../../../common/types';
+import { useGetCaseFiles } from '../../../containers/use_get_case_files';
+import { FilesTable } from '../../files/files_table';
+import { CaseViewTabs } from '../case_view_tabs';
+import { FilesUtilityBar } from '../../files/files_utility_bar';
+
+interface CaseViewFilesProps {
+ caseData: Case;
+}
+
+export const DEFAULT_CASE_FILES_FILTERING_OPTIONS = {
+ page: 0,
+ perPage: 10,
+};
+
+export const CaseViewFiles = ({ caseData }: CaseViewFilesProps) => {
+ const [filteringOptions, setFilteringOptions] = useState(
+ DEFAULT_CASE_FILES_FILTERING_OPTIONS
+ );
+ const {
+ data: caseFiles,
+ isLoading,
+ isPreviousData,
+ } = useGetCaseFiles({
+ ...filteringOptions,
+ caseId: caseData.id,
+ });
+
+ const onTableChange = useCallback(
+ ({ page }: Criteria) => {
+ if (page && !isPreviousData) {
+ setFilteringOptions({
+ ...filteringOptions,
+ page: page.index,
+ perPage: page.size,
+ });
+ }
+ },
+ [filteringOptions, isPreviousData]
+ );
+
+ const onSearchChange = useCallback(
+ (newSearch) => {
+ const trimSearch = newSearch.trim();
+ if (!isEqual(trimSearch, filteringOptions.searchTerm)) {
+ setFilteringOptions({
+ ...filteringOptions,
+ searchTerm: trimSearch,
+ });
+ }
+ },
+ [filteringOptions]
+ );
+
+ const pagination = useMemo(
+ () => ({
+ pageIndex: filteringOptions.page,
+ pageSize: filteringOptions.perPage,
+ totalItemCount: caseFiles?.total ?? 0,
+ pageSizeOptions: [10, 25, 50],
+ showPerPageOptions: true,
+ }),
+ [filteringOptions.page, filteringOptions.perPage, caseFiles?.total]
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+CaseViewFiles.displayName = 'CaseViewFiles';
diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts
index d71c56fc97fca..8fc80c1a0aba3 100644
--- a/x-pack/plugins/cases/public/components/case_view/translations.ts
+++ b/x-pack/plugins/cases/public/components/case_view/translations.ts
@@ -165,6 +165,10 @@ export const ALERTS_TAB = i18n.translate('xpack.cases.caseView.tabs.alerts', {
defaultMessage: 'Alerts',
});
+export const FILES_TAB = i18n.translate('xpack.cases.caseView.tabs.files', {
+ defaultMessage: 'Files',
+});
+
export const ALERTS_EMPTY_DESCRIPTION = i18n.translate(
'xpack.cases.caseView.tabs.alerts.emptyDescription',
{
diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx
index 4e31fffdd7701..1031ec2927609 100644
--- a/x-pack/plugins/cases/public/components/cases_context/index.tsx
+++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx
@@ -5,25 +5,33 @@
* 2.0.
*/
-import type { Dispatch } from 'react';
-import React, { useState, useEffect, useReducer } from 'react';
+import type { Dispatch, ReactNode } from 'react';
+
import { merge } from 'lodash';
+import React, { useCallback, useEffect, useState, useReducer } from 'react';
import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect';
-import { DEFAULT_FEATURES } from '../../../common/constants';
-import { DEFAULT_BASE_PATH } from '../../common/navigation';
-import { useApplication } from './use_application';
+
+import type { ScopedFilesClient } from '@kbn/files-plugin/public';
+
+import { FilesContext } from '@kbn/shared-ux-file-context';
+
import type { CasesContextStoreAction } from './cases_context_reducer';
-import { casesContextReducer, getInitialCasesContextState } from './cases_context_reducer';
import type {
CasesFeaturesAllRequired,
CasesFeatures,
CasesPermissions,
} from '../../containers/types';
-import { CasesGlobalComponents } from './cases_global_components';
import type { ReleasePhase } from '../types';
import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry';
+import { CasesGlobalComponents } from './cases_global_components';
+import { DEFAULT_FEATURES } from '../../../common/constants';
+import { DEFAULT_BASE_PATH } from '../../common/navigation';
+import { useApplication } from './use_application';
+import { casesContextReducer, getInitialCasesContextState } from './cases_context_reducer';
+import { isRegisteredOwner, CASES_FILE_KINDS } from '../../files';
+
export type CasesContextValueDispatch = Dispatch;
export interface CasesContextValue {
@@ -50,6 +58,7 @@ export interface CasesContextProps
basePath?: string;
features?: CasesFeatures;
releasePhase?: ReleasePhase;
+ getFilesClient: (scope: string) => ScopedFilesClient;
}
export const CasesContext = React.createContext(undefined);
@@ -69,6 +78,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({
basePath = DEFAULT_BASE_PATH,
features = {},
releasePhase = 'ga',
+ getFilesClient,
},
}) => {
const { appId, appTitle } = useApplication();
@@ -114,10 +124,35 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({
}
}, [appTitle, appId]);
+ const applyFilesContext = useCallback(
+ (contextChildren: ReactNode) => {
+ if (owner.length === 0) {
+ return contextChildren;
+ }
+
+ if (isRegisteredOwner(owner[0])) {
+ return (
+
+ {contextChildren}
+
+ );
+ } else {
+ throw new Error(
+ 'Invalid owner provided to cases context. See https://github.com/elastic/kibana/blob/main/x-pack/plugins/cases/README.md#casescontext-setup'
+ );
+ }
+ },
+ [getFilesClient, owner]
+ );
+
return isCasesContextValue(value) ? (
-
- {children}
+ {applyFilesContext(
+ <>
+
+ {children}
+ >
+ )}
) : null;
};
diff --git a/x-pack/plugins/cases/public/components/files/add_file.test.tsx b/x-pack/plugins/cases/public/components/files/add_file.test.tsx
new file mode 100644
index 0000000000000..49fcfd6a67273
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/add_file.test.tsx
@@ -0,0 +1,229 @@
+/*
+ * 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 type { FileUploadProps } from '@kbn/shared-ux-file-upload';
+
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import type { AppMockRenderer } from '../../common/mock';
+
+import { constructFileKindIdByOwner } from '../../../common/constants';
+import {
+ buildCasesPermissions,
+ createAppMockRenderer,
+ mockedTestProvidersOwner,
+ mockedFilesClient,
+} from '../../common/mock';
+import { AddFile } from './add_file';
+import { useToasts } from '../../common/lib/kibana';
+
+import { useCreateAttachments } from '../../containers/use_create_attachments';
+import { basicFileMock } from '../../containers/mock';
+
+jest.mock('../../containers/use_create_attachments');
+jest.mock('../../common/lib/kibana');
+
+const useToastsMock = useToasts as jest.Mock;
+const useCreateAttachmentsMock = useCreateAttachments as jest.Mock;
+
+const mockedExternalReferenceId = 'externalReferenceId';
+const validateMetadata = jest.fn();
+const mockFileUpload = jest
+ .fn()
+ .mockImplementation(
+ ({
+ kind,
+ onDone,
+ onError,
+ meta,
+ }: Required>) => (
+ <>
+
+
+
+ >
+ )
+ );
+
+jest.mock('@kbn/shared-ux-file-upload', () => {
+ const original = jest.requireActual('@kbn/shared-ux-file-upload');
+ return {
+ ...original,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ FileUpload: (props: any) => mockFileUpload(props),
+ };
+});
+
+describe('AddFile', () => {
+ let appMockRender: AppMockRenderer;
+
+ const successMock = jest.fn();
+ const errorMock = jest.fn();
+
+ useToastsMock.mockImplementation(() => {
+ return {
+ addSuccess: successMock,
+ addError: errorMock,
+ };
+ });
+
+ const createAttachmentsMock = jest.fn();
+
+ useCreateAttachmentsMock.mockReturnValue({
+ isLoading: false,
+ createAttachments: createAttachmentsMock,
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ appMockRender = createAppMockRenderer();
+ });
+
+ it('renders correctly', async () => {
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument();
+ });
+
+ it('add button disable if user has no creat permission', async () => {
+ appMockRender = createAppMockRenderer({
+ permissions: buildCasesPermissions({ create: false }),
+ });
+
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-add')).toBeDisabled();
+ });
+
+ it('clicking button renders modal', async () => {
+ appMockRender.render();
+
+ userEvent.click(await screen.findByTestId('cases-files-add'));
+
+ expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument();
+ });
+
+ it('createAttachments called with right parameters', async () => {
+ appMockRender.render();
+
+ userEvent.click(await screen.findByTestId('cases-files-add'));
+
+ expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument();
+
+ userEvent.click(await screen.findByTestId('testOnDone'));
+
+ await waitFor(() =>
+ expect(createAttachmentsMock).toBeCalledWith(
+ expect.objectContaining({
+ caseId: 'foobar',
+ caseOwner: mockedTestProvidersOwner[0],
+ data: [
+ {
+ externalReferenceAttachmentTypeId: '.files',
+ externalReferenceId: mockedExternalReferenceId,
+ externalReferenceMetadata: {
+ files: [
+ {
+ createdAt: '2020-02-19T23:06:33.798Z',
+ extension: 'png',
+ mimeType: 'image/png',
+ name: 'my-super-cool-screenshot',
+ },
+ ],
+ },
+ externalReferenceStorage: { soType: 'file', type: 'savedObject' },
+ type: 'externalReference',
+ },
+ ],
+ throwOnError: true,
+ })
+ )
+ );
+
+ await waitFor(() =>
+ expect(successMock).toHaveBeenCalledWith({
+ className: 'eui-textBreakWord',
+ title: `File ${basicFileMock.name} uploaded successfully`,
+ })
+ );
+ });
+
+ it('failed upload displays error toast', async () => {
+ appMockRender.render();
+
+ userEvent.click(await screen.findByTestId('cases-files-add'));
+
+ expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument();
+
+ userEvent.click(await screen.findByTestId('testOnError'));
+
+ expect(errorMock).toHaveBeenCalledWith(
+ { name: 'upload error name', message: 'upload error message' },
+ {
+ title: 'Failed to upload file',
+ }
+ );
+ });
+
+ it('correct metadata is passed to FileUpload component', async () => {
+ const caseId = 'foobar';
+
+ appMockRender.render();
+
+ userEvent.click(await screen.findByTestId('cases-files-add'));
+
+ expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument();
+
+ userEvent.click(await screen.findByTestId('testMetadata'));
+
+ await waitFor(() =>
+ expect(validateMetadata).toHaveBeenCalledWith({ caseId, owner: mockedTestProvidersOwner[0] })
+ );
+ });
+
+ it('filesClient.delete is called correctly if createAttachments fails', async () => {
+ createAttachmentsMock.mockImplementation(() => {
+ throw new Error();
+ });
+
+ appMockRender.render();
+
+ userEvent.click(await screen.findByTestId('cases-files-add'));
+
+ expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument();
+
+ userEvent.click(await screen.findByTestId('testOnDone'));
+
+ await waitFor(() =>
+ expect(mockedFilesClient.delete).toHaveBeenCalledWith({
+ id: mockedExternalReferenceId,
+ kind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
+ })
+ );
+
+ createAttachmentsMock.mockRestore();
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/files/add_file.tsx b/x-pack/plugins/cases/public/components/files/add_file.tsx
new file mode 100644
index 0000000000000..ce2f87719cf88
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/add_file.tsx
@@ -0,0 +1,151 @@
+/*
+ * 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 {
+ EuiButton,
+ EuiModal,
+ EuiModalBody,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+} from '@elastic/eui';
+import React, { useCallback, useState } from 'react';
+
+import type { UploadedFile } from '@kbn/shared-ux-file-upload/src/file_upload';
+
+import { FILE_SO_TYPE } from '@kbn/files-plugin/common';
+import { FileUpload } from '@kbn/shared-ux-file-upload';
+import { useFilesContext } from '@kbn/shared-ux-file-context';
+
+import type { Owner } from '../../../common/constants/types';
+
+import { CommentType, ExternalReferenceStorageType } from '../../../common';
+import { FILE_ATTACHMENT_TYPE } from '../../../common/api';
+import { constructFileKindIdByOwner } from '../../../common/constants';
+import { useCasesToast } from '../../common/use_cases_toast';
+import { useCreateAttachments } from '../../containers/use_create_attachments';
+import { useCasesContext } from '../cases_context/use_cases_context';
+import * as i18n from './translations';
+import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_page';
+
+interface AddFileProps {
+ caseId: string;
+}
+
+const AddFileComponent: React.FC = ({ caseId }) => {
+ const { owner, permissions } = useCasesContext();
+ const { client: filesClient } = useFilesContext();
+ const { showDangerToast, showErrorToast, showSuccessToast } = useCasesToast();
+ const { isLoading, createAttachments } = useCreateAttachments();
+ const refreshAttachmentsTable = useRefreshCaseViewPage();
+ const [isModalVisible, setIsModalVisible] = useState(false);
+
+ const closeModal = () => setIsModalVisible(false);
+ const showModal = () => setIsModalVisible(true);
+
+ const onError = useCallback(
+ (error) => {
+ showErrorToast(error, {
+ title: i18n.FAILED_UPLOAD,
+ });
+ },
+ [showErrorToast]
+ );
+
+ const onUploadDone = useCallback(
+ async (chosenFiles: UploadedFile[]) => {
+ if (chosenFiles.length === 0) {
+ showDangerToast(i18n.FAILED_UPLOAD);
+ return;
+ }
+
+ const file = chosenFiles[0];
+
+ try {
+ await createAttachments({
+ caseId,
+ caseOwner: owner[0],
+ data: [
+ {
+ type: CommentType.externalReference,
+ externalReferenceId: file.id,
+ externalReferenceStorage: {
+ type: ExternalReferenceStorageType.savedObject,
+ soType: FILE_SO_TYPE,
+ },
+ externalReferenceAttachmentTypeId: FILE_ATTACHMENT_TYPE,
+ externalReferenceMetadata: {
+ files: [
+ {
+ name: file.fileJSON.name,
+ extension: file.fileJSON.extension ?? '',
+ mimeType: file.fileJSON.mimeType ?? '',
+ createdAt: file.fileJSON.created,
+ },
+ ],
+ },
+ },
+ ],
+ updateCase: refreshAttachmentsTable,
+ throwOnError: true,
+ });
+
+ showSuccessToast(i18n.SUCCESSFUL_UPLOAD_FILE_NAME(file.fileJSON.name));
+ } catch (error) {
+ // error toast is handled inside createAttachments
+
+ // we need to delete the file if attachment creation failed
+ await filesClient.delete({
+ kind: constructFileKindIdByOwner(owner[0] as Owner),
+ id: file.id,
+ });
+ }
+
+ closeModal();
+ },
+ [
+ caseId,
+ createAttachments,
+ filesClient,
+ owner,
+ refreshAttachmentsTable,
+ showDangerToast,
+ showSuccessToast,
+ ]
+ );
+
+ return (
+ <>
+
+ {i18n.ADD_FILE}
+
+ {isModalVisible && (
+
+
+ {i18n.ADD_FILE}
+
+
+
+
+
+ )}
+ >
+ );
+};
+AddFileComponent.displayName = 'AddFile';
+
+export const AddFile = React.memo(AddFileComponent);
diff --git a/x-pack/plugins/cases/public/components/files/file_preview.test.tsx b/x-pack/plugins/cases/public/components/files/file_preview.test.tsx
new file mode 100644
index 0000000000000..48efac8aa64b5
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/file_preview.test.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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, waitFor } from '@testing-library/react';
+
+import type { AppMockRenderer } from '../../common/mock';
+
+import { constructFileKindIdByOwner } from '../../../common/constants';
+import { createAppMockRenderer, mockedTestProvidersOwner } from '../../common/mock';
+import { basicFileMock } from '../../containers/mock';
+import { FilePreview } from './file_preview';
+
+describe('FilePreview', () => {
+ let appMockRender: AppMockRenderer;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ appMockRender = createAppMockRenderer();
+ });
+
+ it('FilePreview rendered correctly', async () => {
+ const mockGetDownloadRef = jest.fn();
+
+ appMockRender.render(
+
+ );
+
+ await waitFor(() =>
+ expect(mockGetDownloadRef).toBeCalledWith({
+ id: basicFileMock.id,
+ fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
+ })
+ );
+
+ expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument();
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/files/file_preview.tsx b/x-pack/plugins/cases/public/components/files/file_preview.tsx
new file mode 100644
index 0000000000000..19429861ba389
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/file_preview.tsx
@@ -0,0 +1,55 @@
+/*
+ * 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 styled from 'styled-components';
+
+import type { FileJSON } from '@kbn/shared-ux-file-types';
+
+import { EuiOverlayMask, EuiFocusTrap, EuiImage } from '@elastic/eui';
+
+import type { Owner } from '../../../common/constants/types';
+
+import { constructFileKindIdByOwner } from '../../../common/constants';
+import { useCasesContext } from '../cases_context/use_cases_context';
+
+interface FilePreviewProps {
+ closePreview: () => void;
+ getDownloadHref: (args: Pick, 'id' | 'fileKind'>) => string;
+ selectedFile: FileJSON;
+}
+
+const StyledOverlayMask = styled(EuiOverlayMask)`
+ padding-block-end: 0vh !important;
+
+ img {
+ max-height: 85vh;
+ max-width: 85vw;
+ object-fit: contain;
+ }
+`;
+
+export const FilePreview = ({ closePreview, selectedFile, getDownloadHref }: FilePreviewProps) => {
+ const { owner } = useCasesContext();
+
+ return (
+
+
+
+
+
+ );
+};
+
+FilePreview.displayName = 'FilePreview';
diff --git a/x-pack/plugins/cases/public/components/files/files_table.test.tsx b/x-pack/plugins/cases/public/components/files/files_table.test.tsx
new file mode 100644
index 0000000000000..8caa94c4caa21
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/files_table.test.tsx
@@ -0,0 +1,173 @@
+/*
+ * 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, waitFor, within } from '@testing-library/react';
+
+import { basicFileMock } from '../../containers/mock';
+import type { AppMockRenderer } from '../../common/mock';
+
+import { constructFileKindIdByOwner } from '../../../common/constants';
+import {
+ createAppMockRenderer,
+ mockedFilesClient,
+ mockedTestProvidersOwner,
+} from '../../common/mock';
+import { FilesTable } from './files_table';
+import userEvent from '@testing-library/user-event';
+
+describe('FilesTable', () => {
+ const onTableChange = jest.fn();
+ const defaultProps = {
+ caseId: 'foobar',
+ items: [basicFileMock],
+ pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 1 },
+ isLoading: false,
+ onChange: onTableChange,
+ };
+
+ let appMockRender: AppMockRenderer;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ appMockRender = createAppMockRenderer();
+ });
+
+ it('renders correctly', async () => {
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-table-results-count')).toBeInTheDocument();
+ expect(await screen.findByTestId('cases-files-table-filename')).toBeInTheDocument();
+ expect(await screen.findByTestId('cases-files-table-filetype')).toBeInTheDocument();
+ expect(await screen.findByTestId('cases-files-table-date-added')).toBeInTheDocument();
+ expect(await screen.findByTestId('cases-files-table-action-download')).toBeInTheDocument();
+ expect(await screen.findByTestId('cases-files-table-action-delete')).toBeInTheDocument();
+ });
+
+ it('renders loading state', async () => {
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-table-loading')).toBeInTheDocument();
+ });
+
+ it('renders empty table', async () => {
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-table-empty')).toBeInTheDocument();
+ });
+
+ it('renders single result count properly', async () => {
+ const mockPagination = { pageIndex: 0, pageSize: 10, totalItemCount: 4 };
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-table-results-count')).toHaveTextContent(
+ 'Showing 4 files'
+ );
+ });
+
+ it('non image rows dont open file preview', async () => {
+ const nonImageFileMock = { ...basicFileMock, mimeType: 'something/else' };
+
+ appMockRender.render();
+
+ userEvent.click(
+ await within(await screen.findByTestId('cases-files-table-filename')).findByTitle(
+ 'No preview available'
+ )
+ );
+
+ expect(await screen.queryByTestId('cases-files-image-preview')).not.toBeInTheDocument();
+ });
+
+ it('image rows open file preview', async () => {
+ appMockRender.render();
+
+ userEvent.click(
+ await screen.findByRole('button', {
+ name: `${basicFileMock.name}.${basicFileMock.extension}`,
+ })
+ );
+
+ expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument();
+ });
+
+ it('different mimeTypes are displayed correctly', async () => {
+ const mockPagination = { pageIndex: 0, pageSize: 10, totalItemCount: 7 };
+ appMockRender.render(
+
+ );
+
+ expect((await screen.findAllByText('Unknown')).length).toBe(4);
+ expect(await screen.findByText('Application')).toBeInTheDocument();
+ expect(await screen.findByText('Text')).toBeInTheDocument();
+ expect(await screen.findByText('Image')).toBeInTheDocument();
+ });
+
+ it('download button renders correctly', async () => {
+ appMockRender.render();
+
+ expect(mockedFilesClient.getDownloadHref).toBeCalledTimes(1);
+ expect(mockedFilesClient.getDownloadHref).toHaveBeenCalledWith({
+ fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
+ id: basicFileMock.id,
+ });
+
+ expect(await screen.findByTestId('cases-files-table-action-download')).toBeInTheDocument();
+ });
+
+ it('go to next page calls onTableChange with correct values', async () => {
+ const mockPagination = { pageIndex: 0, pageSize: 1, totalItemCount: 2 };
+
+ appMockRender.render(
+
+ );
+
+ userEvent.click(await screen.findByTestId('pagination-button-next'));
+
+ await waitFor(() =>
+ expect(onTableChange).toHaveBeenCalledWith({
+ page: { index: mockPagination.pageIndex + 1, size: mockPagination.pageSize },
+ })
+ );
+ });
+
+ it('go to previous page calls onTableChange with correct values', async () => {
+ const mockPagination = { pageIndex: 1, pageSize: 1, totalItemCount: 2 };
+
+ appMockRender.render(
+
+ );
+
+ userEvent.click(await screen.findByTestId('pagination-button-previous'));
+
+ await waitFor(() =>
+ expect(onTableChange).toHaveBeenCalledWith({
+ page: { index: mockPagination.pageIndex - 1, size: mockPagination.pageSize },
+ })
+ );
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/files/files_table.tsx b/x-pack/plugins/cases/public/components/files/files_table.tsx
new file mode 100644
index 0000000000000..247ca60fd3e8f
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/files_table.tsx
@@ -0,0 +1,91 @@
+/*
+ * 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, { useState } from 'react';
+
+import type { Pagination, EuiBasicTableProps } from '@elastic/eui';
+import type { FileJSON } from '@kbn/shared-ux-file-types';
+
+import { EuiBasicTable, EuiLoadingContent, EuiSpacer, EuiText, EuiEmptyPrompt } from '@elastic/eui';
+import { useFilesContext } from '@kbn/shared-ux-file-context';
+
+import * as i18n from './translations';
+import { useFilesTableColumns } from './use_files_table_columns';
+import { FilePreview } from './file_preview';
+import { AddFile } from './add_file';
+
+const EmptyFilesTable = ({ caseId }: { caseId: string }) => (
+ {i18n.NO_FILES}}
+ data-test-subj="cases-files-table-empty"
+ titleSize="xs"
+ actions={}
+ />
+);
+
+EmptyFilesTable.displayName = 'EmptyFilesTable';
+
+interface FilesTableProps {
+ caseId: string;
+ isLoading: boolean;
+ items: FileJSON[];
+ onChange: EuiBasicTableProps['onChange'];
+ pagination: Pagination;
+}
+
+export const FilesTable = ({ caseId, items, pagination, onChange, isLoading }: FilesTableProps) => {
+ const { client: filesClient } = useFilesContext();
+ const [isPreviewVisible, setIsPreviewVisible] = useState(false);
+ const [selectedFile, setSelectedFile] = useState();
+
+ const closePreview = () => setIsPreviewVisible(false);
+ const showPreview = (file: FileJSON) => {
+ setSelectedFile(file);
+ setIsPreviewVisible(true);
+ };
+
+ const columns = useFilesTableColumns({
+ showPreview,
+ getDownloadHref: filesClient.getDownloadHref,
+ });
+
+ return isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ {pagination.totalItemCount > 0 && (
+ <>
+
+
+ {i18n.SHOWING_FILES(pagination.totalItemCount)}
+
+ >
+ )}
+
+ }
+ />
+ {isPreviewVisible && selectedFile !== undefined && (
+
+ )}
+ >
+ );
+};
+
+FilesTable.displayName = 'FilesTable';
diff --git a/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx b/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx
new file mode 100644
index 0000000000000..bfac1998a857a
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx
@@ -0,0 +1,42 @@
+/*
+ * 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 type { AppMockRenderer } from '../../common/mock';
+import { createAppMockRenderer } from '../../common/mock';
+import userEvent from '@testing-library/user-event';
+import { FilesUtilityBar } from './files_utility_bar';
+
+const defaultProps = {
+ caseId: 'foobar',
+ onSearch: jest.fn(),
+};
+
+describe('FilesUtilityBar', () => {
+ let appMockRender: AppMockRenderer;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ appMockRender = createAppMockRenderer();
+ });
+
+ it('renders correctly', async () => {
+ appMockRender.render();
+
+ expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument();
+ expect(await screen.findByTestId('cases-files-search')).toBeInTheDocument();
+ });
+
+ it('search text passed correctly to callback', async () => {
+ appMockRender.render();
+
+ await userEvent.type(screen.getByTestId('cases-files-search'), 'My search{enter}');
+ expect(defaultProps.onSearch).toBeCalledWith('My search');
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx b/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx
new file mode 100644
index 0000000000000..420985aa610b4
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx
@@ -0,0 +1,38 @@
+/*
+ * 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 { EuiFlexGroup, EuiFlexItem, EuiFieldSearch } from '@elastic/eui';
+import { AddFile } from './add_file';
+
+import * as i18n from './translations';
+
+interface FilesUtilityBarProps {
+ caseId: string;
+ onSearch: (newSearch: string) => void;
+}
+
+export const FilesUtilityBar = ({ caseId, onSearch }: FilesUtilityBarProps) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+FilesUtilityBar.displayName = 'FilesUtilityBar';
diff --git a/x-pack/plugins/cases/public/components/files/translations.tsx b/x-pack/plugins/cases/public/components/files/translations.tsx
new file mode 100644
index 0000000000000..6ea6080d3cb33
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/translations.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const ACTIONS = i18n.translate('xpack.cases.caseView.files.actions', {
+ defaultMessage: 'Actions',
+});
+
+export const ADD_FILE = i18n.translate('xpack.cases.caseView.files.addFile', {
+ defaultMessage: 'Add File',
+});
+
+export const CLOSE_MODAL = i18n.translate('xpack.cases.caseView.files.closeModal', {
+ defaultMessage: 'Close',
+});
+
+export const DATE_ADDED = i18n.translate('xpack.cases.caseView.files.dateAdded', {
+ defaultMessage: 'Date Added',
+});
+
+export const DELETE_FILE = i18n.translate('xpack.cases.caseView.files.deleteFile', {
+ defaultMessage: 'Delete File',
+});
+
+export const DOWNLOAD_FILE = i18n.translate('xpack.cases.caseView.files.downloadFile', {
+ defaultMessage: 'Download File',
+});
+
+export const FILES_TABLE = i18n.translate('xpack.cases.caseView.files.filesTable', {
+ defaultMessage: 'Files table',
+});
+
+export const NAME = i18n.translate('xpack.cases.caseView.files.name', {
+ defaultMessage: 'Name',
+});
+
+export const NO_FILES = i18n.translate('xpack.cases.caseView.files.noFilesAvailable', {
+ defaultMessage: 'No files available',
+});
+
+export const NO_PREVIEW = i18n.translate('xpack.cases.caseView.files.noPreviewAvailable', {
+ defaultMessage: 'No preview available',
+});
+
+export const RESULTS_COUNT = i18n.translate('xpack.cases.caseView.files.resultsCount', {
+ defaultMessage: 'Showing',
+});
+
+export const TYPE = i18n.translate('xpack.cases.caseView.files.type', {
+ defaultMessage: 'Type',
+});
+
+export const SEARCH_PLACEHOLDER = i18n.translate('xpack.cases.caseView.files.searchPlaceholder', {
+ defaultMessage: 'Search files',
+});
+
+export const FAILED_UPLOAD = i18n.translate('xpack.cases.caseView.files.failedUpload', {
+ defaultMessage: 'Failed to upload file',
+});
+
+export const UNKNOWN_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.unknownMimeType', {
+ defaultMessage: 'Unknown',
+});
+
+export const SUCCESSFUL_UPLOAD_FILE_NAME = (fileName: string) =>
+ i18n.translate('xpack.cases.caseView.files.successfulUploadFileName', {
+ defaultMessage: 'File {fileName} uploaded successfully',
+ values: { fileName },
+ });
+
+export const SHOWING_FILES = (totalFiles: number) =>
+ i18n.translate('xpack.cases.caseView.files.showingFilesTitle', {
+ values: { totalFiles },
+ defaultMessage: 'Showing {totalFiles} {totalFiles, plural, =1 {file} other {files}}',
+ });
diff --git a/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx b/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx
new file mode 100644
index 0000000000000..6ee4cbff94c6f
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx
@@ -0,0 +1,76 @@
+/*
+ * 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 type { FilesTableColumnsProps } from './use_files_table_columns';
+import { useFilesTableColumns } from './use_files_table_columns';
+import type { AppMockRenderer } from '../../common/mock';
+import { createAppMockRenderer } from '../../common/mock';
+import { renderHook } from '@testing-library/react-hooks';
+
+describe('useCasesColumns ', () => {
+ let appMockRender: AppMockRenderer;
+
+ const useCasesColumnsProps: FilesTableColumnsProps = {
+ showPreview: () => {},
+ getDownloadHref: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ appMockRender = createAppMockRenderer();
+ });
+
+ it('return all files table columns correctly', async () => {
+ const { result } = renderHook(() => useFilesTableColumns(useCasesColumnsProps), {
+ wrapper: appMockRender.AppWrapper,
+ });
+
+ expect(result.current).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "data-test-subj": "cases-files-table-filename",
+ "name": "Name",
+ "render": [Function],
+ "width": "60%",
+ },
+ Object {
+ "data-test-subj": "cases-files-table-filetype",
+ "name": "Type",
+ "render": [Function],
+ },
+ Object {
+ "data-test-subj": "cases-files-table-date-added",
+ "dataType": "date",
+ "field": "created",
+ "name": "Date Added",
+ },
+ Object {
+ "actions": Array [
+ Object {
+ "description": "Download File",
+ "isPrimary": true,
+ "name": "Download",
+ "render": [Function],
+ },
+ Object {
+ "color": "danger",
+ "data-test-subj": "cases-files-table-action-delete",
+ "description": "Delete File",
+ "icon": "trash",
+ "isPrimary": true,
+ "name": "Delete",
+ "onClick": [Function],
+ "type": "icon",
+ },
+ ],
+ "name": "Actions",
+ "width": "120px",
+ },
+ ]
+ `);
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx b/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx
new file mode 100644
index 0000000000000..7477f71128162
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx
@@ -0,0 +1,95 @@
+/*
+ * 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 type { EuiBasicTableColumn } from '@elastic/eui';
+import type { FileJSON } from '@kbn/shared-ux-file-types';
+
+import { EuiLink, EuiButtonIcon } from '@elastic/eui';
+
+import type { Owner } from '../../../common/constants/types';
+
+import { constructFileKindIdByOwner } from '../../../common/constants';
+import { useCasesContext } from '../cases_context/use_cases_context';
+import * as i18n from './translations';
+import { isImage, parseMimeType } from './utils';
+
+export interface FilesTableColumnsProps {
+ showPreview: (file: FileJSON) => void;
+ getDownloadHref: (args: Pick, 'id' | 'fileKind'>) => string;
+}
+
+export const useFilesTableColumns = ({
+ showPreview,
+ getDownloadHref,
+}: FilesTableColumnsProps): Array> => {
+ const { owner } = useCasesContext();
+
+ return [
+ {
+ name: i18n.NAME,
+ 'data-test-subj': 'cases-files-table-filename',
+ render: (attachment: FileJSON) => {
+ const fileName = `${attachment.name}.${attachment.extension}`;
+ if (isImage(attachment)) {
+ return showPreview(attachment)}>{fileName};
+ } else {
+ return {fileName};
+ }
+ },
+ width: '60%',
+ },
+ {
+ name: i18n.TYPE,
+ 'data-test-subj': 'cases-files-table-filetype',
+ render: (attachment: FileJSON) => {
+ return {parseMimeType(attachment.mimeType)};
+ },
+ },
+ {
+ name: i18n.DATE_ADDED,
+ field: 'created',
+ 'data-test-subj': 'cases-files-table-date-added',
+ dataType: 'date',
+ },
+ {
+ name: i18n.ACTIONS,
+ width: '120px',
+ actions: [
+ {
+ name: 'Download',
+ isPrimary: true,
+ description: i18n.DOWNLOAD_FILE,
+ render: (attachment: FileJSON) => {
+ return (
+
+ );
+ },
+ },
+ {
+ name: 'Delete',
+ isPrimary: true,
+ description: i18n.DELETE_FILE,
+ color: 'danger',
+ icon: 'trash',
+ type: 'icon',
+ onClick: () => {},
+ 'data-test-subj': 'cases-files-table-action-delete',
+ },
+ ],
+ },
+ ];
+};
diff --git a/x-pack/plugins/cases/public/components/files/utils.test.tsx b/x-pack/plugins/cases/public/components/files/utils.test.tsx
new file mode 100644
index 0000000000000..c45a7b32d779c
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/utils.test.tsx
@@ -0,0 +1,49 @@
+/*
+ * 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 { isImage, parseMimeType } from './utils';
+
+import type { FileJSON } from '@kbn/shared-ux-file-types';
+import { imageMimeTypes, textMimeTypes } from '../../../common/constants/mime_types';
+
+describe('isImage', () => {
+ it('should return true for allowed image mime types', () => {
+ isImage({ mimeType: imageMimeTypes[0] } as FileJSON);
+
+ // @ts-ignore
+ expect(imageMimeTypes.reduce((acc, curr) => acc && isImage({ mimeType: curr }))).toBeTruthy();
+ });
+
+ it('should return false for allowed non-image mime types', () => {
+ isImage({ mimeType: imageMimeTypes[0] } as FileJSON);
+
+ // @ts-ignore
+ expect(textMimeTypes.reduce((acc, curr) => acc && isImage({ mimeType: curr }))).toBeFalsy();
+ });
+});
+
+describe('parseMimeType', () => {
+ it('should return Unknown for empty strings', () => {
+ expect(parseMimeType('')).toBe('Unknown');
+ });
+
+ it('should return Unknown for undefined', () => {
+ expect(parseMimeType(undefined)).toBe('Unknown');
+ });
+
+ it('should return Unknown for strings starting with forward slash', () => {
+ expect(parseMimeType('/start')).toBe('Unknown');
+ });
+
+ it('should return Unknown for strings with no forward slash', () => {
+ expect(parseMimeType('no-slash')).toBe('Unknown');
+ });
+
+ it('should return capitalize first letter for valid strings', () => {
+ expect(parseMimeType('foo/bar')).toBe('Foo');
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/files/utils.tsx b/x-pack/plugins/cases/public/components/files/utils.tsx
new file mode 100644
index 0000000000000..dd18ee7d8e9cc
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/files/utils.tsx
@@ -0,0 +1,26 @@
+/*
+ * 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 type { FileJSON } from '@kbn/shared-ux-file-types';
+
+import * as i18n from './translations';
+
+export const isImage = (file: FileJSON) => file.mimeType?.startsWith('image/');
+
+export const parseMimeType = (mimeType: string | undefined) => {
+ if (typeof mimeType === 'undefined') {
+ return i18n.UNKNOWN_MIME_TYPE;
+ }
+
+ const result = mimeType.split('/');
+
+ if (result.length <= 1 || result[0] === '') {
+ return i18n.UNKNOWN_MIME_TYPE;
+ }
+
+ return result[0].charAt(0).toUpperCase() + result[0].slice(1);
+};
diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts
index 904f52295c23c..6ad841e3b7f94 100644
--- a/x-pack/plugins/cases/public/containers/constants.ts
+++ b/x-pack/plugins/cases/public/containers/constants.ts
@@ -23,6 +23,8 @@ export const casesQueriesKeys = {
cases: (params: unknown) => [...casesQueriesKeys.casesList(), 'all-cases', params] as const,
caseView: () => [...casesQueriesKeys.all, 'case'] as const,
case: (id: string) => [...casesQueriesKeys.caseView(), id] as const,
+ caseFiles: (id: string, params: unknown) =>
+ [...casesQueriesKeys.case(id), 'attachments', params] as const,
caseMetrics: (id: string, features: SingleCaseMetricsFeature[]) =>
[...casesQueriesKeys.case(id), 'metrics', features] as const,
caseConnectors: (id: string) => [...casesQueriesKeys.case(id), 'connectors'],
diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts
index 4c6439231245a..4c0e9d67be0a8 100644
--- a/x-pack/plugins/cases/public/containers/mock.ts
+++ b/x-pack/plugins/cases/public/containers/mock.ts
@@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
+import type { FileJSON } from '@kbn/shared-ux-file-types';
import type { ActionLicense, Cases, Case, CasesStatus, CaseUserActions, Comment } from './types';
@@ -240,6 +241,20 @@ export const basicCase: Case = {
assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }],
};
+export const basicFileMock: FileJSON = {
+ id: '7d47d130-bcec-11ed-afa1-0242ac120002',
+ name: 'my-super-cool-screenshot',
+ mimeType: 'image/png',
+ created: basicCreatedAt,
+ updated: basicCreatedAt,
+ size: 999,
+ meta: '',
+ alt: '',
+ fileKind: '',
+ status: 'READY',
+ extension: 'png',
+};
+
export const caseWithAlerts = {
...basicCase,
totalAlerts: 2,
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx
new file mode 100644
index 0000000000000..7a47eab3f36d0
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx
@@ -0,0 +1,70 @@
+/*
+ * 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 { renderHook, act } from '@testing-library/react-hooks';
+
+import { basicCase } from './mock';
+
+import type { AppMockRenderer } from '../common/mock';
+import { mockedTestProvidersOwner, mockedFilesClient, createAppMockRenderer } from '../common/mock';
+import { useToasts } from '../common/lib/kibana';
+import { useGetCaseFiles } from './use_get_case_files';
+import { constructFileKindIdByOwner } from '../../common/constants/files';
+
+jest.mock('../common/lib/kibana');
+
+const hookParams = {
+ caseId: basicCase.id,
+ page: 1,
+ perPage: 1,
+ searchTerm: 'foobar',
+};
+
+const expectedCallParams = {
+ kind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
+ page: hookParams.page + 1,
+ name: `*${hookParams.searchTerm}*`,
+ perPage: hookParams.perPage,
+ meta: { caseId: hookParams.caseId },
+};
+
+describe('useGetCaseFiles', () => {
+ let appMockRender: AppMockRenderer;
+
+ beforeEach(() => {
+ appMockRender = createAppMockRenderer();
+ jest.clearAllMocks();
+ });
+
+ it('shows an error toast when filesClient.list throws', async () => {
+ const addError = jest.fn();
+ (useToasts as jest.Mock).mockReturnValue({ addError });
+
+ mockedFilesClient.list = jest.fn().mockImplementation(() => {
+ throw new Error('Something went wrong');
+ });
+
+ const { waitForNextUpdate } = renderHook(() => useGetCaseFiles(hookParams), {
+ wrapper: appMockRender.AppWrapper,
+ });
+ await waitForNextUpdate();
+
+ expect(mockedFilesClient.list).toBeCalledWith(expectedCallParams);
+ expect(addError).toHaveBeenCalled();
+ });
+
+ it('calls filesClient.list with correct arguments', async () => {
+ await act(async () => {
+ const { waitForNextUpdate } = renderHook(() => useGetCaseFiles(hookParams), {
+ wrapper: appMockRender.AppWrapper,
+ });
+ await waitForNextUpdate();
+
+ expect(mockedFilesClient.list).toBeCalledWith(expectedCallParams);
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_files.tsx b/x-pack/plugins/cases/public/containers/use_get_case_files.tsx
new file mode 100644
index 0000000000000..8e594dba4cbd5
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_get_case_files.tsx
@@ -0,0 +1,61 @@
+/*
+ * 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 type { FileJSON } from '@kbn/shared-ux-file-types';
+import type { UseQueryResult } from '@tanstack/react-query';
+
+import { useFilesContext } from '@kbn/shared-ux-file-context';
+import { useQuery } from '@tanstack/react-query';
+
+import type { Owner } from '../../common/constants/types';
+import type { ServerError } from '../types';
+
+import { constructFileKindIdByOwner } from '../../common/constants';
+import { useCasesToast } from '../common/use_cases_toast';
+import { casesQueriesKeys } from './constants';
+import * as i18n from './translations';
+import { useCasesContext } from '../components/cases_context/use_cases_context';
+
+export interface CaseFilesFilteringOptions {
+ page: number;
+ perPage: number;
+ searchTerm?: string;
+}
+
+export interface GetCaseFilesParams extends CaseFilesFilteringOptions {
+ caseId: string;
+}
+
+export const useGetCaseFiles = ({
+ caseId,
+ page,
+ perPage,
+ searchTerm,
+}: GetCaseFilesParams): UseQueryResult<{ files: FileJSON[]; total: number }> => {
+ const { owner } = useCasesContext();
+ const { showErrorToast } = useCasesToast();
+ const { client: filesClient } = useFilesContext();
+
+ return useQuery(
+ casesQueriesKeys.caseFiles(caseId, { page, perPage, searchTerm }),
+ () => {
+ return filesClient.list({
+ kind: constructFileKindIdByOwner(owner[0] as Owner),
+ page: page + 1,
+ ...(searchTerm && { name: `*${searchTerm}*` }),
+ perPage,
+ meta: { caseId },
+ });
+ },
+ {
+ keepPreviousData: true,
+ onError: (error: ServerError) => {
+ showErrorToast(error, { title: i18n.ERROR_TITLE });
+ },
+ }
+ );
+};
diff --git a/x-pack/plugins/cases/public/files/index.ts b/x-pack/plugins/cases/public/files/index.ts
index 9da40d059cd2a..c3a051adf08d9 100644
--- a/x-pack/plugins/cases/public/files/index.ts
+++ b/x-pack/plugins/cases/public/files/index.ts
@@ -20,10 +20,13 @@ const buildFileKind = (owner: Owner): FileKindBrowser => {
};
};
+export const isRegisteredOwner = (ownerToCheck: string): ownerToCheck is Owner =>
+ Object.hasOwn(CASES_FILE_KINDS, ownerToCheck);
+
/**
* The file kind definition for interacting with the file service for the UI
*/
-const CASES_FILE_KINDS: Record = {
+export const CASES_FILE_KINDS: Record = {
[APP_ID]: buildFileKind(APP_ID),
[SECURITY_SOLUTION_OWNER]: buildFileKind(SECURITY_SOLUTION_OWNER),
[OBSERVABILITY_OWNER]: buildFileKind(OBSERVABILITY_OWNER),
diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts
index 83b0f2fb0f009..6b828e72b279a 100644
--- a/x-pack/plugins/cases/public/plugin.ts
+++ b/x-pack/plugins/cases/public/plugin.ts
@@ -115,6 +115,7 @@ export class CasesUiPlugin
const getCasesContext = getCasesContextLazy({
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
});
return {
@@ -125,6 +126,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
getCasesContext,
getRecentCases: (props) =>
@@ -132,6 +134,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
// @deprecated Please use the hook getUseCasesAddToNewCaseFlyout
getCreateCaseFlyout: (props) =>
@@ -139,6 +142,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
// @deprecated Please use the hook getUseCasesAddToExistingCaseModal
getAllCasesSelectorModal: (props) =>
@@ -146,6 +150,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
},
hooks: {
diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts
index a72f9e5449a95..765226c6f328e 100644
--- a/x-pack/plugins/cases/public/types.ts
+++ b/x-pack/plugins/cases/public/types.ts
@@ -32,6 +32,7 @@ import type {
CasesStatusRequest,
CommentRequestAlertType,
CommentRequestExternalReferenceNoSOType,
+ CommentRequestExternalReferenceSOType,
CommentRequestPersistableStateType,
CommentRequestUserType,
} from '../common/api';
@@ -160,7 +161,8 @@ export type SupportedCaseAttachment =
| CommentRequestAlertType
| CommentRequestUserType
| CommentRequestPersistableStateType
- | CommentRequestExternalReferenceNoSOType;
+ | CommentRequestExternalReferenceNoSOType
+ | CommentRequestExternalReferenceSOType;
export type CaseAttachments = SupportedCaseAttachment[];
export type CaseAttachmentWithoutOwner = DistributiveOmit;
diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json
index 7c703b9819300..0686a8ca66e83 100644
--- a/x-pack/plugins/cases/tsconfig.json
+++ b/x-pack/plugins/cases/tsconfig.json
@@ -57,6 +57,9 @@
"@kbn/shared-ux-router",
"@kbn/files-plugin",
"@kbn/shared-ux-file-types",
+ "@kbn/shared-ux-file-context",
+ "@kbn/shared-ux-file-upload",
+ "@kbn/shared-ux-file-mocks",
"@kbn/saved-objects-finder-plugin",
"@kbn/saved-objects-management-plugin",
],
diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc b/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc
index f80753ebbe744..53ad5d6ef363e 100644
--- a/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc
+++ b/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc
@@ -8,6 +8,7 @@
"browser": false,
"requiredPlugins": [
"features",
+ "files",
"cases"
],
"optionalPlugins": [