From 78133a7f44a8d728512bed4021c9e93b40b9a686 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 27 Mar 2023 22:40:31 +0200 Subject: [PATCH] [Cases] Detail View Attachments Table (#152941) Fixes #151723 This PR adds a way for users to: View a list of all files attached to a case(with pagination). Attach files to a case. Preview image files attached to a case. Search for files attached to a case by file name. Download files attached to a case. --- .../file/file_upload/impl/src/file_upload.tsx | 2 +- .../cases/common/constants/mime_types.ts | 8 +- x-pack/plugins/cases/common/types.ts | 1 + x-pack/plugins/cases/public/application.tsx | 21 +- .../ui/get_all_cases_selector_modal.tsx | 6 +- .../cases/public/client/ui/get_cases.tsx | 6 +- .../public/client/ui/get_cases_context.tsx | 12 +- .../client/ui/get_create_case_flyout.tsx | 6 +- .../public/client/ui/get_recent_cases.tsx | 6 +- .../public/common/mock/test_providers.tsx | 45 +++- .../public/common/use_cases_toast.test.tsx | 19 ++ .../cases/public/common/use_cases_toast.tsx | 3 + .../cases/public/components/app/index.tsx | 10 +- .../case_view/case_view_page.test.tsx | 3 +- .../components/case_view/case_view_page.tsx | 2 + .../case_view/case_view_tabs.test.tsx | 24 ++ .../components/case_view/case_view_tabs.tsx | 6 +- .../components/case_view_files.test.tsx | 73 ++++++ .../case_view/components/case_view_files.tsx | 104 ++++++++ .../components/case_view/translations.ts | 4 + .../public/components/cases_context/index.tsx | 53 +++- .../public/components/files/add_file.test.tsx | 229 ++++++++++++++++++ .../public/components/files/add_file.tsx | 151 ++++++++++++ .../components/files/file_preview.test.tsx | 46 ++++ .../public/components/files/file_preview.tsx | 55 +++++ .../components/files/files_table.test.tsx | 173 +++++++++++++ .../public/components/files/files_table.tsx | 91 +++++++ .../files/files_utility_bar.test.tsx | 42 ++++ .../components/files/files_utility_bar.tsx | 38 +++ .../public/components/files/translations.tsx | 80 ++++++ .../files/use_files_table_columns.test.tsx | 76 ++++++ .../files/use_files_table_columns.tsx | 95 ++++++++ .../public/components/files/utils.test.tsx | 49 ++++ .../cases/public/components/files/utils.tsx | 26 ++ .../cases/public/containers/constants.ts | 2 + .../plugins/cases/public/containers/mock.ts | 15 ++ .../containers/use_get_case_files.test.tsx | 70 ++++++ .../public/containers/use_get_case_files.tsx | 61 +++++ x-pack/plugins/cases/public/files/index.ts | 5 +- x-pack/plugins/cases/public/plugin.ts | 5 + x-pack/plugins/cases/public/types.ts | 4 +- x-pack/plugins/cases/tsconfig.json | 3 + .../common/plugins/cases/kibana.jsonc | 1 + 43 files changed, 1689 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx create mode 100644 x-pack/plugins/cases/public/components/files/add_file.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/add_file.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_preview.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/file_preview.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_table.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_table.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/files_utility_bar.tsx create mode 100644 x-pack/plugins/cases/public/components/files/translations.tsx create mode 100644 x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx create mode 100644 x-pack/plugins/cases/public/components/files/utils.test.tsx create mode 100644 x-pack/plugins/cases/public/components/files/utils.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_case_files.tsx 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": [