mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Cases] Attaching files to cases (#154436)
Fixes #151595 ## Summary In this PR we will be merging a feature branch into `main`. This feature branch is a collection of several different PRs with file functionality for cases. - https://github.com/elastic/kibana/pull/152941 - https://github.com/elastic/kibana/pull/153957 - https://github.com/elastic/kibana/pull/154432 - https://github.com/elastic/kibana/pull/153853 Most of the code was already reviewed so this will mainly be used for testing. - Files tab in the case detail view. - Attach files to a case. - View a list of all files attached to a case (with pagination). - Preview image files attached to a case. - Search for files attached to a case by file name. - Download files attached to a case. - Users are now able to see file activity in the case detail view. - Image files have a different icon and a clickable file name to preview. - Other files have a standard "document" icon and the name is not clickable. - The file can be downloaded by clicking the download icon. ## Release notes Support file attachments in Cases. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b81a2705df
commit
0a38f85002
80 changed files with 3442 additions and 117 deletions
|
@ -7,7 +7,7 @@ pageLoadAssetSize:
|
|||
banners: 17946
|
||||
bfetch: 22837
|
||||
canvas: 1066647
|
||||
cases: 144442
|
||||
cases: 170000
|
||||
charts: 55000
|
||||
cloud: 21076
|
||||
cloudChat: 19894
|
||||
|
|
|
@ -19,7 +19,7 @@ import { context } from './context';
|
|||
/**
|
||||
* An object representing an uploaded file
|
||||
*/
|
||||
interface UploadedFile<Meta = unknown> {
|
||||
export interface UploadedFile<Meta = unknown> {
|
||||
/**
|
||||
* The ID that was generated for the uploaded file
|
||||
*/
|
||||
|
|
|
@ -9,15 +9,15 @@ import * as rt from 'io-ts';
|
|||
import { MAX_DELETE_FILES } from '../../../constants';
|
||||
import { limitedArraySchema, NonEmptyString } from '../../../schema';
|
||||
|
||||
export const SingleFileAttachmentMetadataRt = rt.type({
|
||||
name: rt.string,
|
||||
extension: rt.string,
|
||||
mimeType: rt.string,
|
||||
created: rt.string,
|
||||
});
|
||||
|
||||
export const FileAttachmentMetadataRt = rt.type({
|
||||
files: rt.array(
|
||||
rt.type({
|
||||
name: rt.string,
|
||||
extension: rt.string,
|
||||
mimeType: rt.string,
|
||||
createdAt: rt.string,
|
||||
})
|
||||
),
|
||||
files: rt.array(SingleFileAttachmentMetadataRt),
|
||||
});
|
||||
|
||||
export type FileAttachmentMetadata = rt.TypeOf<typeof FileAttachmentMetadataRt>;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -24,4 +24,5 @@ export type SnakeToCamelCase<T> = T extends Record<string, unknown>
|
|||
export enum CASE_VIEW_PAGE_TABS {
|
||||
ALERTS = 'alerts',
|
||||
ACTIVITY = 'activity',
|
||||
FILES = 'files',
|
||||
}
|
||||
|
|
|
@ -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<CasesAppWithContextProps> = React.memo(
|
||||
({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry }) => {
|
||||
({
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
}) => {
|
||||
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
|
||||
|
||||
return (
|
||||
|
@ -48,6 +55,7 @@ const CasesAppWithContext: React.FC<CasesAppWithContextProps> = React.memo(
|
|||
<CasesApp
|
||||
externalReferenceAttachmentTypeRegistry={externalReferenceAttachmentTypeRegistry}
|
||||
persistableStateAttachmentTypeRegistry={persistableStateAttachmentTypeRegistry}
|
||||
getFilesClient={getFilesClient}
|
||||
/>
|
||||
</StyledComponentsThemeProvider>
|
||||
);
|
||||
|
@ -78,6 +86,7 @@ export const App: React.FC<{ deps: RenderAppProps }> = ({ deps }) => {
|
|||
deps.externalReferenceAttachmentTypeRegistry
|
||||
}
|
||||
persistableStateAttachmentTypeRegistry={deps.persistableStateAttachmentTypeRegistry}
|
||||
getFilesClient={pluginsStart.files.filesClientFactory.asScoped}
|
||||
/>
|
||||
</Router>
|
||||
</KibanaContextProvider>
|
||||
|
|
|
@ -13,19 +13,38 @@ import type {
|
|||
} from '../../../common/api';
|
||||
import type { Case } from '../../containers/types';
|
||||
|
||||
export interface AttachmentAction {
|
||||
export enum AttachmentActionType {
|
||||
BUTTON = 'button',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
interface BaseAttachmentAction {
|
||||
type: AttachmentActionType;
|
||||
label: string;
|
||||
isPrimary?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ButtonAttachmentAction extends BaseAttachmentAction {
|
||||
type: AttachmentActionType.BUTTON;
|
||||
onClick: () => void;
|
||||
iconType: string;
|
||||
label: string;
|
||||
color?: EuiButtonProps['color'];
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
interface CustomAttachmentAction extends BaseAttachmentAction {
|
||||
type: AttachmentActionType.CUSTOM;
|
||||
render: () => JSX.Element;
|
||||
}
|
||||
|
||||
export type AttachmentAction = ButtonAttachmentAction | CustomAttachmentAction;
|
||||
|
||||
export interface AttachmentViewObject<Props = {}> {
|
||||
timelineAvatar?: EuiCommentProps['timelineAvatar'];
|
||||
getActions?: (props: Props) => AttachmentAction[];
|
||||
event?: EuiCommentProps['event'];
|
||||
children?: React.LazyExoticComponent<React.FC<Props>>;
|
||||
hideDefaultActions?: boolean;
|
||||
}
|
||||
|
||||
export interface CommonAttachmentViewProps {
|
||||
|
@ -46,8 +65,9 @@ export interface AttachmentType<Props> {
|
|||
id: string;
|
||||
icon: IconType;
|
||||
displayName: string;
|
||||
getAttachmentViewObject: () => AttachmentViewObject<Props>;
|
||||
getAttachmentViewObject: (props: Props) => AttachmentViewObject<Props>;
|
||||
getAttachmentRemovalObject?: (props: Props) => Pick<AttachmentViewObject<Props>, 'event'>;
|
||||
hideDefaultActions?: boolean;
|
||||
}
|
||||
|
||||
export type ExternalReferenceAttachmentType = AttachmentType<ExternalReferenceAttachmentViewProps>;
|
||||
|
|
|
@ -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<AllCasesSelectorModalProps> = lazy(
|
||||
|
@ -23,6 +25,7 @@ const AllCasesSelectorModalLazy: React.FC<AllCasesSelectorModalProps> = lazy(
|
|||
export const getAllCasesSelectorModalLazy = ({
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
owner,
|
||||
permissions,
|
||||
hiddenStatuses,
|
||||
|
@ -33,6 +36,7 @@ export const getAllCasesSelectorModalLazy = ({
|
|||
value={{
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
owner,
|
||||
permissions,
|
||||
}}
|
||||
|
|
|
@ -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<CasesProps> = lazy(() => import('../../components/app/routes'));
|
||||
|
@ -22,6 +24,7 @@ const CasesRoutesLazy: React.FC<CasesProps> = 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,
|
||||
|
|
|
@ -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 (
|
||||
<Suspense fallback={<EuiLoadingSpinner />}>
|
||||
|
@ -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<GetCasesContextProps>) => {
|
||||
const CasesProviderLazyWrapperWithRegistry: React.FC<GetCasesContextProps> = ({
|
||||
children,
|
||||
|
@ -64,6 +71,7 @@ export const getCasesContextLazy = ({
|
|||
{...props}
|
||||
externalReferenceAttachmentTypeRegistry={externalReferenceAttachmentTypeRegistry}
|
||||
persistableStateAttachmentTypeRegistry={persistableStateAttachmentTypeRegistry}
|
||||
getFilesClient={getFilesClient}
|
||||
>
|
||||
{children}
|
||||
</CasesProviderLazyWrapper>
|
||||
|
|
|
@ -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<CreateCaseFlyoutProps> = lazy(
|
||||
|
@ -23,6 +25,7 @@ export const CreateCaseFlyoutLazy: React.FC<CreateCaseFlyoutProps> = lazy(
|
|||
export const getCreateCaseFlyoutLazy = ({
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
owner,
|
||||
permissions,
|
||||
features,
|
||||
|
@ -35,6 +38,7 @@ export const getCreateCaseFlyoutLazy = ({
|
|||
value={{
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
owner,
|
||||
permissions,
|
||||
features,
|
||||
|
|
|
@ -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<RecentCasesProps> = lazy(
|
||||
|
@ -23,6 +25,7 @@ const RecentCasesLazy: React.FC<RecentCasesProps> = lazy(
|
|||
export const getRecentCasesLazy = ({
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
owner,
|
||||
permissions,
|
||||
maxCasesToShow,
|
||||
|
@ -31,6 +34,7 @@ export const getRecentCasesLazy = ({
|
|||
value={{
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
owner,
|
||||
permissions,
|
||||
}}
|
||||
|
|
|
@ -9,22 +9,30 @@
|
|||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { euiDarkVars } from '@kbn/ui-theme';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
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 type { ScopedFilesClient } from '@kbn/files-plugin/public';
|
||||
|
||||
import { euiDarkVars } from '@kbn/ui-theme';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { createMockFilesClient } from '@kbn/shared-ux-file-mocks';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { render as reactRender } from '@testing-library/react';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { FilesContext } from '@kbn/shared-ux-file-context';
|
||||
|
||||
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
|
||||
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';
|
||||
|
@ -43,17 +51,35 @@ type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResul
|
|||
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
const mockGetFilesClient = () => {
|
||||
const mockedFilesClient = createMockFilesClient() as unknown as DeeplyMockedKeys<
|
||||
ScopedFilesClient<unknown>
|
||||
>;
|
||||
|
||||
mockedFilesClient.getFileKind.mockImplementation(() => ({
|
||||
id: 'test',
|
||||
maxSizeBytes: 10000,
|
||||
http: {},
|
||||
}));
|
||||
|
||||
return () => mockedFilesClient;
|
||||
};
|
||||
|
||||
export const mockedTestProvidersOwner = [SECURITY_SOLUTION_OWNER];
|
||||
|
||||
/** A utility for wrapping children in the providers required to run most tests */
|
||||
const TestProvidersComponent: React.FC<TestProviderProps> = ({
|
||||
children,
|
||||
features,
|
||||
owner = [SECURITY_SOLUTION_OWNER],
|
||||
owner = mockedTestProvidersOwner,
|
||||
permissions = allCasesPermissions(),
|
||||
releasePhase = 'ga',
|
||||
externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(),
|
||||
persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(),
|
||||
license,
|
||||
}) => {
|
||||
const services = createStartServicesMock({ license });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
@ -67,7 +93,7 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
|
|||
},
|
||||
});
|
||||
|
||||
const services = createStartServicesMock({ license });
|
||||
const getFilesClient = mockGetFilesClient();
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
|
@ -82,9 +108,10 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
|
|||
features,
|
||||
owner,
|
||||
permissions,
|
||||
getFilesClient,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<FilesContext client={createMockFilesClient()}>{children}</FilesContext>
|
||||
</CasesProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
|
@ -104,6 +131,7 @@ export interface AppMockRenderer {
|
|||
coreStart: StartServices;
|
||||
queryClient: QueryClient;
|
||||
AppWrapper: React.FC<{ children: React.ReactElement }>;
|
||||
getFilesClient: () => ScopedFilesClient;
|
||||
}
|
||||
|
||||
export const testQueryClient = new QueryClient({
|
||||
|
@ -125,7 +153,7 @@ export const testQueryClient = new QueryClient({
|
|||
|
||||
export const createAppMockRenderer = ({
|
||||
features,
|
||||
owner = [SECURITY_SOLUTION_OWNER],
|
||||
owner = mockedTestProvidersOwner,
|
||||
permissions = allCasesPermissions(),
|
||||
releasePhase = 'ga',
|
||||
externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(),
|
||||
|
@ -147,6 +175,8 @@ export const createAppMockRenderer = ({
|
|||
},
|
||||
});
|
||||
|
||||
const getFilesClient = mockGetFilesClient();
|
||||
|
||||
const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => (
|
||||
<I18nProvider>
|
||||
<KibanaContextProvider services={services}>
|
||||
|
@ -161,6 +191,7 @@ export const createAppMockRenderer = ({
|
|||
owner,
|
||||
permissions,
|
||||
releasePhase,
|
||||
getFilesClient,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -188,6 +219,7 @@ export const createAppMockRenderer = ({
|
|||
AppWrapper,
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
|||
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,22 @@ 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({
|
||||
className: 'eui-textBreakWord',
|
||||
title: 'my danger toast',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -169,6 +169,9 @@ export const useCasesToast = () => {
|
|||
showSuccessToast: (title: string) => {
|
||||
toasts.addSuccess({ title, className: 'eui-textBreakWord' });
|
||||
},
|
||||
showDangerToast: (title: string) => {
|
||||
toasts.addDanger({ title, className: 'eui-textBreakWord' });
|
||||
},
|
||||
showInfoToast: (title: string, text?: string) => {
|
||||
toasts.addInfo({
|
||||
title,
|
||||
|
|
|
@ -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<CasesAppProps> = ({
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
}) => {
|
||||
const userCapabilities = useApplicationCapabilities();
|
||||
|
||||
|
@ -33,6 +38,7 @@ const CasesAppComponent: React.FC<CasesAppProps> = ({
|
|||
{getCasesLazy({
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient,
|
||||
owner: [APP_OWNER],
|
||||
useFetchAlertData: () => [false, {}],
|
||||
permissions: userCapabilities.generalCases,
|
||||
|
|
|
@ -16,6 +16,7 @@ import type { Case } from '../../../common/ui/types';
|
|||
import { useAllCasesNavigation } from '../../common/navigation';
|
||||
import { useCasesContext } from '../cases_context/use_cases_context';
|
||||
import { useCasesToast } from '../../common/use_cases_toast';
|
||||
import { AttachmentActionType } from '../../client/attachment_framework/types';
|
||||
|
||||
interface CaseViewActions {
|
||||
caseData: Case;
|
||||
|
@ -40,6 +41,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
|
|||
const propertyActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'copyClipboard',
|
||||
label: i18n.COPY_ID_ACTION_LABEL,
|
||||
onClick: () => {
|
||||
|
@ -50,6 +52,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
|
|||
...(currentExternalIncident != null && !isEmpty(currentExternalIncident?.externalUrl)
|
||||
? [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'popout',
|
||||
label: i18n.VIEW_INCIDENT(currentExternalIncident?.externalTitle ?? ''),
|
||||
onClick: () => window.open(currentExternalIncident?.externalUrl, '_blank'),
|
||||
|
@ -59,6 +62,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
|
|||
...(permissions.delete
|
||||
? [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'trash',
|
||||
label: i18n.DELETE_CASE(),
|
||||
color: 'danger' as const,
|
||||
|
|
|
@ -493,8 +493,9 @@ describe('CaseViewPage', () => {
|
|||
it('renders tabs correctly', async () => {
|
||||
const result = appMockRenderer.render(<CaseViewPage {...caseProps} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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<CaseViewPageProps>(
|
|||
{activeTabId === CASE_VIEW_PAGE_TABS.ALERTS && features.alerts.enabled && (
|
||||
<CaseViewAlerts caseData={caseData} />
|
||||
)}
|
||||
{activeTabId === CASE_VIEW_PAGE_TABS.FILES && <CaseViewFiles caseData={caseData} />}
|
||||
</EuiFlexGroup>
|
||||
</WhitePageWrapperNoBorder>
|
||||
{timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null}
|
||||
|
|
|
@ -8,23 +8,28 @@
|
|||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import type { AppMockRenderer } from '../../common/mock';
|
||||
import { createAppMockRenderer } from '../../common/mock';
|
||||
import '../../common/mock/match_media';
|
||||
import { useCaseViewNavigation } from '../../common/navigation/hooks';
|
||||
import type { UseGetCase } from '../../containers/use_get_case';
|
||||
import type { CaseViewTabsProps } from './case_view_tabs';
|
||||
|
||||
import { CASE_VIEW_PAGE_TABS } from '../../../common/types';
|
||||
import '../../common/mock/match_media';
|
||||
import { createAppMockRenderer } from '../../common/mock';
|
||||
import { useCaseViewNavigation } from '../../common/navigation/hooks';
|
||||
import { useGetCase } from '../../containers/use_get_case';
|
||||
import { CaseViewTabs } from './case_view_tabs';
|
||||
import { caseData, defaultGetCase } from './mocks';
|
||||
import type { CaseViewTabsProps } from './case_view_tabs';
|
||||
import { CASE_VIEW_PAGE_TABS } from '../../../common/types';
|
||||
import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats';
|
||||
|
||||
jest.mock('../../containers/use_get_case');
|
||||
jest.mock('../../common/navigation/hooks');
|
||||
jest.mock('../../common/hooks');
|
||||
jest.mock('../../containers/use_get_case_file_stats');
|
||||
|
||||
const useFetchCaseMock = useGetCase as jest.Mock;
|
||||
const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock;
|
||||
const useGetCaseFileStatsMock = useGetCaseFileStats as jest.Mock;
|
||||
|
||||
const mockGetCase = (props: Partial<UseGetCase> = {}) => {
|
||||
const data = {
|
||||
|
@ -45,8 +50,10 @@ export const caseProps: CaseViewTabsProps = {
|
|||
|
||||
describe('CaseViewTabs', () => {
|
||||
let appMockRenderer: AppMockRenderer;
|
||||
const data = { total: 3 };
|
||||
|
||||
beforeEach(() => {
|
||||
useGetCaseFileStatsMock.mockReturnValue({ data });
|
||||
mockGetCase();
|
||||
|
||||
appMockRenderer = createAppMockRenderer();
|
||||
|
@ -62,6 +69,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 +90,40 @@ describe('CaseViewTabs', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('shows the files tab as active', async () => {
|
||||
appMockRenderer.render(<CaseViewTabs {...caseProps} activeTab={CASE_VIEW_PAGE_TABS.FILES} />);
|
||||
|
||||
expect(await screen.findByTestId('case-view-tab-title-files')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the files tab with the correct count and colour', async () => {
|
||||
appMockRenderer.render(<CaseViewTabs {...caseProps} activeTab={CASE_VIEW_PAGE_TABS.FILES} />);
|
||||
|
||||
const badge = await screen.findByTestId('case-view-files-stats-badge');
|
||||
|
||||
expect(badge.getAttribute('class')).toMatch(/accent/);
|
||||
expect(badge).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('do not show count on the files tab if the call isLoading', async () => {
|
||||
useGetCaseFileStatsMock.mockReturnValue({ isLoading: true, data });
|
||||
|
||||
appMockRenderer.render(<CaseViewTabs {...caseProps} activeTab={CASE_VIEW_PAGE_TABS.FILES} />);
|
||||
|
||||
expect(screen.queryByTestId('case-view-files-stats-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the files tab count has a different colour if the tab is not active', async () => {
|
||||
appMockRenderer.render(<CaseViewTabs {...caseProps} activeTab={CASE_VIEW_PAGE_TABS.ALERTS} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByTestId('case-view-files-stats-badge')).getAttribute('class')
|
||||
).not.toMatch(/accent/);
|
||||
});
|
||||
|
||||
it('navigates to the activity tab when the activity tab is clicked', async () => {
|
||||
const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView;
|
||||
appMockRenderer.render(<CaseViewTabs {...caseProps} />);
|
||||
|
@ -109,4 +151,18 @@ describe('CaseViewTabs', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to the files tab when the files tab is clicked', async () => {
|
||||
const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView;
|
||||
appMockRenderer.render(<CaseViewTabs {...caseProps} />);
|
||||
|
||||
userEvent.click(await screen.findByTestId('case-view-tab-title-files'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateToCaseViewMock).toHaveBeenCalledWith({
|
||||
detailName: caseData.id,
|
||||
tabId: CASE_VIEW_PAGE_TABS.FILES,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,20 +5,49 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiBetaBadge, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
|
||||
import { EuiBetaBadge, EuiNotificationBadge, EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
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';
|
||||
import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats';
|
||||
|
||||
const ExperimentalBadge = styled(EuiBetaBadge)`
|
||||
margin-left: 5px;
|
||||
`;
|
||||
|
||||
const StyledNotificationBadge = styled(EuiNotificationBadge)`
|
||||
margin-left: 5px;
|
||||
`;
|
||||
|
||||
const FilesTab = ({
|
||||
activeTab,
|
||||
fileStatsData,
|
||||
isLoading,
|
||||
}: {
|
||||
activeTab: string;
|
||||
fileStatsData: { total: number } | undefined;
|
||||
isLoading: boolean;
|
||||
}) => (
|
||||
<>
|
||||
{FILES_TAB}
|
||||
{!isLoading && fileStatsData && (
|
||||
<StyledNotificationBadge
|
||||
data-test-subj="case-view-files-stats-badge"
|
||||
color={activeTab === CASE_VIEW_PAGE_TABS.FILES ? 'accent' : 'subdued'}
|
||||
>
|
||||
{fileStatsData.total > 0 ? fileStatsData.total : 0}
|
||||
</StyledNotificationBadge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
FilesTab.displayName = 'FilesTab';
|
||||
|
||||
export interface CaseViewTabsProps {
|
||||
caseData: Case;
|
||||
activeTab: CASE_VIEW_PAGE_TABS;
|
||||
|
@ -27,6 +56,7 @@ export interface CaseViewTabsProps {
|
|||
export const CaseViewTabs = React.memo<CaseViewTabsProps>(({ caseData, activeTab }) => {
|
||||
const { features } = useCasesContext();
|
||||
const { navigateToCaseView } = useCaseViewNavigation();
|
||||
const { data: fileStatsData, isLoading } = useGetCaseFileStats({ caseId: caseData.id });
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
|
@ -56,8 +86,14 @@ export const CaseViewTabs = React.memo<CaseViewTabsProps>(({ caseData, activeTab
|
|||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: CASE_VIEW_PAGE_TABS.FILES,
|
||||
name: (
|
||||
<FilesTab isLoading={isLoading} fileStatsData={fileStatsData} activeTab={activeTab} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[features.alerts.enabled, features.alerts.isExperimental]
|
||||
[activeTab, features.alerts.enabled, features.alerts.isExperimental, fileStatsData, isLoading]
|
||||
);
|
||||
|
||||
const renderTabs = useCallback(() => {
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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(<CaseViewFiles caseData={caseData} />);
|
||||
|
||||
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(<CaseViewFiles caseData={caseData} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking table pagination triggers calls to useGetCaseFiles', async () => {
|
||||
appMockRender.render(<CaseViewFiles caseData={caseData} />);
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('changing perPage value triggers calls to useGetCaseFiles', async () => {
|
||||
const targetPagination = 50;
|
||||
|
||||
appMockRender.render(<CaseViewFiles caseData={caseData} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByTestId('tablePaginationPopoverButton'));
|
||||
|
||||
const pageSizeOption = screen.getByTestId('tablePagination-50-rows');
|
||||
|
||||
pageSizeOption.style.pointerEvents = 'all';
|
||||
|
||||
userEvent.click(pageSizeOption);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useGetCaseFilesMock).toHaveBeenCalledWith({
|
||||
caseId: basicCase.id,
|
||||
page: DEFAULT_CASE_FILES_FILTERING_OPTIONS.page,
|
||||
perPage: targetPagination,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('search by word triggers calls to useGetCaseFiles', async () => {
|
||||
appMockRender.render(<CaseViewFiles caseData={caseData} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByTestId('cases-files-search'), 'Foobar{enter}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useGetCaseFilesMock).toHaveBeenCalledWith({
|
||||
caseId: basicCase.id,
|
||||
page: DEFAULT_CASE_FILES_FILTERING_OPTIONS.page,
|
||||
perPage: DEFAULT_CASE_FILES_FILTERING_OPTIONS.perPage,
|
||||
searchTerm: 'Foobar',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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<CaseFilesFilteringOptions>(
|
||||
DEFAULT_CASE_FILES_FILTERING_OPTIONS
|
||||
);
|
||||
const {
|
||||
data: caseFiles,
|
||||
isLoading,
|
||||
isPreviousData,
|
||||
} = useGetCaseFiles({
|
||||
...filteringOptions,
|
||||
caseId: caseData.id,
|
||||
});
|
||||
|
||||
const onTableChange = useCallback(
|
||||
({ page }: Criteria<FileJSON>) => {
|
||||
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 (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<CaseViewTabs caseData={caseData} activeTab={CASE_VIEW_PAGE_TABS.FILES} />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<FilesUtilityBar caseId={caseData.id} onSearch={onSearchChange} />
|
||||
<FilesTable
|
||||
caseId={caseData.id}
|
||||
isLoading={isLoading}
|
||||
items={caseFiles?.files ?? []}
|
||||
onChange={onTableChange}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
CaseViewFiles.displayName = 'CaseViewFiles';
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -5,25 +5,34 @@
|
|||
* 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 { constructFileKindIdByOwner } from '../../../common/files';
|
||||
import { DEFAULT_BASE_PATH } from '../../common/navigation';
|
||||
import { useApplication } from './use_application';
|
||||
import { casesContextReducer, getInitialCasesContextState } from './cases_context_reducer';
|
||||
import { isRegisteredOwner } from '../../files';
|
||||
|
||||
export type CasesContextValueDispatch = Dispatch<CasesContextStoreAction>;
|
||||
|
||||
export interface CasesContextValue {
|
||||
|
@ -50,6 +59,7 @@ export interface CasesContextProps
|
|||
basePath?: string;
|
||||
features?: CasesFeatures;
|
||||
releasePhase?: ReleasePhase;
|
||||
getFilesClient: (scope: string) => ScopedFilesClient;
|
||||
}
|
||||
|
||||
export const CasesContext = React.createContext<CasesContextValue | undefined>(undefined);
|
||||
|
@ -69,6 +79,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({
|
|||
basePath = DEFAULT_BASE_PATH,
|
||||
features = {},
|
||||
releasePhase = 'ga',
|
||||
getFilesClient,
|
||||
},
|
||||
}) => {
|
||||
const { appId, appTitle } = useApplication();
|
||||
|
@ -114,10 +125,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 (
|
||||
<FilesContext client={getFilesClient(constructFileKindIdByOwner(owner[0]))}>
|
||||
{contextChildren}
|
||||
</FilesContext>
|
||||
);
|
||||
} 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) ? (
|
||||
<CasesContext.Provider value={value}>
|
||||
<CasesGlobalComponents state={state} />
|
||||
{children}
|
||||
{applyFilesContext(
|
||||
<>
|
||||
<CasesGlobalComponents state={state} />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</CasesContext.Provider>
|
||||
) : null;
|
||||
};
|
||||
|
|
241
x-pack/plugins/cases/public/components/files/add_file.test.tsx
Normal file
241
x-pack/plugins/cases/public/components/files/add_file.test.tsx
Normal file
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* 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 * as api from '../../containers/api';
|
||||
import {
|
||||
buildCasesPermissions,
|
||||
createAppMockRenderer,
|
||||
mockedTestProvidersOwner,
|
||||
} from '../../common/mock';
|
||||
import { AddFile } from './add_file';
|
||||
import { useToasts } from '../../common/lib/kibana';
|
||||
|
||||
import { useCreateAttachments } from '../../containers/use_create_attachments';
|
||||
import { basicCaseId, basicFileMock } from '../../containers/mock';
|
||||
|
||||
jest.mock('../../containers/api');
|
||||
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<Pick<FileUploadProps, 'kind' | 'onDone' | 'onError' | 'meta'>>) => (
|
||||
<>
|
||||
<button
|
||||
data-test-subj="testOnDone"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onDone([{ id: mockedExternalReferenceId, kind, fileJSON: { ...basicFileMock, meta } }])
|
||||
}
|
||||
>
|
||||
{'test'}
|
||||
</button>
|
||||
<button
|
||||
data-test-subj="testOnError"
|
||||
type="button"
|
||||
onClick={() => onError({ name: 'upload error name', message: 'upload error message' })}
|
||||
>
|
||||
{'test'}
|
||||
</button>
|
||||
<button data-test-subj="testMetadata" type="button" onClick={() => validateMetadata(meta)}>
|
||||
{'test'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
jest.mock('@kbn/shared-ux-file-upload', () => {
|
||||
const original = jest.requireActual('@kbn/shared-ux-file-upload');
|
||||
return {
|
||||
...original,
|
||||
FileUpload: (props: unknown) => 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(<AddFile caseId={'foobar'} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('AddFile is not rendered if user has no create permission', async () => {
|
||||
appMockRender = createAppMockRenderer({
|
||||
permissions: buildCasesPermissions({ create: false }),
|
||||
});
|
||||
|
||||
appMockRender.render(<AddFile caseId={'foobar'} />);
|
||||
|
||||
expect(screen.queryByTestId('cases-files-add')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('AddFile is not rendered if user has no update permission', async () => {
|
||||
appMockRender = createAppMockRenderer({
|
||||
permissions: buildCasesPermissions({ update: false }),
|
||||
});
|
||||
|
||||
appMockRender.render(<AddFile caseId={'foobar'} />);
|
||||
|
||||
expect(screen.queryByTestId('cases-files-add')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking button renders modal', async () => {
|
||||
appMockRender.render(<AddFile caseId={'foobar'} />);
|
||||
|
||||
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(<AddFile caseId={'foobar'} />);
|
||||
|
||||
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({
|
||||
caseId: 'foobar',
|
||||
caseOwner: mockedTestProvidersOwner[0],
|
||||
data: [
|
||||
{
|
||||
externalReferenceAttachmentTypeId: '.files',
|
||||
externalReferenceId: mockedExternalReferenceId,
|
||||
externalReferenceMetadata: {
|
||||
files: [
|
||||
{
|
||||
created: '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,
|
||||
updateCase: expect.any(Function),
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(successMock).toHaveBeenCalledWith({
|
||||
className: 'eui-textBreakWord',
|
||||
title: `File ${basicFileMock.name} uploaded successfully`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('failed upload displays error toast', async () => {
|
||||
appMockRender.render(<AddFile caseId={'foobar'} />);
|
||||
|
||||
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(<AddFile caseId={caseId} />);
|
||||
|
||||
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({
|
||||
caseIds: [caseId],
|
||||
owner: [mockedTestProvidersOwner[0]],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deleteFileAttachments is called correctly if createAttachments fails', async () => {
|
||||
const spyOnDeleteFileAttachments = jest.spyOn(api, 'deleteFileAttachments');
|
||||
|
||||
createAttachmentsMock.mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
|
||||
appMockRender.render(<AddFile caseId={basicCaseId} />);
|
||||
|
||||
userEvent.click(await screen.findByTestId('cases-files-add'));
|
||||
|
||||
expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(await screen.findByTestId('testOnDone'));
|
||||
|
||||
expect(spyOnDeleteFileAttachments).toHaveBeenCalledWith({
|
||||
caseId: basicCaseId,
|
||||
fileIds: [mockedExternalReferenceId],
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
|
||||
createAttachmentsMock.mockRestore();
|
||||
});
|
||||
});
|
141
x-pack/plugins/cases/public/components/files/add_file.tsx
Normal file
141
x-pack/plugins/cases/public/components/files/add_file.tsx
Normal file
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* 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,
|
||||
EuiFlexItem,
|
||||
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 { constructFileKindIdByOwner } from '../../../common/files';
|
||||
import type { Owner } from '../../../common/constants/types';
|
||||
|
||||
import { CommentType, ExternalReferenceStorageType } from '../../../common';
|
||||
import { FILE_ATTACHMENT_TYPE } from '../../../common/api';
|
||||
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';
|
||||
import { deleteFileAttachments } from '../../containers/api';
|
||||
|
||||
interface AddFileProps {
|
||||
caseId: string;
|
||||
}
|
||||
|
||||
const AddFileComponent: React.FC<AddFileProps> = ({ caseId }) => {
|
||||
const { owner, permissions } = useCasesContext();
|
||||
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 ?? '',
|
||||
created: 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
|
||||
const abortCtrlRef = new AbortController();
|
||||
return deleteFileAttachments({ caseId, fileIds: [file.id], signal: abortCtrlRef.signal });
|
||||
}
|
||||
|
||||
closeModal();
|
||||
},
|
||||
[caseId, createAttachments, owner, refreshAttachmentsTable, showDangerToast, showSuccessToast]
|
||||
);
|
||||
|
||||
return permissions.create && permissions.update ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="cases-files-add"
|
||||
iconType="plusInCircle"
|
||||
isDisabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
onClick={showModal}
|
||||
>
|
||||
{i18n.ADD_FILE}
|
||||
</EuiButton>
|
||||
{isModalVisible && (
|
||||
<EuiModal data-test-subj="cases-files-add-modal" onClose={closeModal}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>{i18n.ADD_FILE}</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
<FileUpload
|
||||
kind={constructFileKindIdByOwner(owner[0] as Owner)}
|
||||
onDone={onUploadDone}
|
||||
onError={onError}
|
||||
meta={{ caseIds: [caseId], owner: [owner[0]] }}
|
||||
/>
|
||||
</EuiModalBody>
|
||||
</EuiModal>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
) : null;
|
||||
};
|
||||
AddFileComponent.displayName = 'AddFile';
|
||||
|
||||
export const AddFile = React.memo(AddFileComponent);
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* 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 { AppMockRenderer } from '../../common/mock';
|
||||
|
||||
import { buildCasesPermissions, createAppMockRenderer } from '../../common/mock';
|
||||
import { basicCaseId, basicFileMock } from '../../containers/mock';
|
||||
import { useDeleteFileAttachment } from '../../containers/use_delete_file_attachment';
|
||||
import { FileDeleteButton } from './file_delete_button';
|
||||
|
||||
jest.mock('../../containers/use_delete_file_attachment');
|
||||
|
||||
const useDeleteFileAttachmentMock = useDeleteFileAttachment as jest.Mock;
|
||||
|
||||
describe('FileDeleteButton', () => {
|
||||
let appMockRender: AppMockRenderer;
|
||||
const mutate = jest.fn();
|
||||
|
||||
useDeleteFileAttachmentMock.mockReturnValue({ isLoading: false, mutate });
|
||||
|
||||
describe('isIcon', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appMockRender = createAppMockRenderer();
|
||||
});
|
||||
|
||||
it('renders delete button correctly', async () => {
|
||||
appMockRender.render(
|
||||
<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} isIcon={true} />
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
|
||||
|
||||
expect(useDeleteFileAttachmentMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking delete button opens the confirmation modal', async () => {
|
||||
appMockRender.render(
|
||||
<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} isIcon={true} />
|
||||
);
|
||||
|
||||
const deleteButton = await screen.findByTestId('cases-files-delete-button');
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking delete button in the confirmation modal calls deleteFileAttachment with proper params', async () => {
|
||||
appMockRender.render(
|
||||
<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} isIcon={true} />
|
||||
);
|
||||
|
||||
const deleteButton = await screen.findByTestId('cases-files-delete-button');
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(await screen.findByTestId('confirmModalConfirmButton'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
caseId: basicCaseId,
|
||||
fileId: basicFileMock.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('delete button is not rendered if user has no delete permission', async () => {
|
||||
appMockRender = createAppMockRenderer({
|
||||
permissions: buildCasesPermissions({ delete: false }),
|
||||
});
|
||||
|
||||
appMockRender.render(
|
||||
<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} isIcon={true} />
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('cases-files-delete-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('not isIcon', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appMockRender = createAppMockRenderer();
|
||||
});
|
||||
|
||||
it('renders delete button correctly', async () => {
|
||||
appMockRender.render(<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
|
||||
|
||||
expect(useDeleteFileAttachmentMock).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking delete button opens the confirmation modal', async () => {
|
||||
appMockRender.render(<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} />);
|
||||
|
||||
const deleteButton = await screen.findByTestId('cases-files-delete-button');
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking delete button in the confirmation modal calls deleteFileAttachment with proper params', async () => {
|
||||
appMockRender.render(<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} />);
|
||||
|
||||
const deleteButton = await screen.findByTestId('cases-files-delete-button');
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(await screen.findByTestId('confirmModalConfirmButton'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
caseId: basicCaseId,
|
||||
fileId: basicFileMock.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('delete button is not rendered if user has no delete permission', async () => {
|
||||
appMockRender = createAppMockRenderer({
|
||||
permissions: buildCasesPermissions({ delete: false }),
|
||||
});
|
||||
|
||||
appMockRender.render(<FileDeleteButton caseId={basicCaseId} fileId={basicFileMock.id} />);
|
||||
|
||||
expect(screen.queryByTestId('cases-files-delete-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
|
||||
import * as i18n from './translations';
|
||||
import { useDeleteFileAttachment } from '../../containers/use_delete_file_attachment';
|
||||
import { useDeletePropertyAction } from '../user_actions/property_actions/use_delete_property_action';
|
||||
import { DeleteAttachmentConfirmationModal } from '../user_actions/delete_attachment_confirmation_modal';
|
||||
import { useCasesContext } from '../cases_context/use_cases_context';
|
||||
|
||||
interface FileDeleteButtonProps {
|
||||
caseId: string;
|
||||
fileId: string;
|
||||
isIcon?: boolean;
|
||||
}
|
||||
|
||||
const FileDeleteButtonComponent: React.FC<FileDeleteButtonProps> = ({ caseId, fileId, isIcon }) => {
|
||||
const { permissions } = useCasesContext();
|
||||
const { isLoading, mutate: deleteFileAttachment } = useDeleteFileAttachment();
|
||||
|
||||
const { showDeletionModal, onModalOpen, onConfirm, onCancel } = useDeletePropertyAction({
|
||||
onDelete: () => deleteFileAttachment({ caseId, fileId }),
|
||||
});
|
||||
|
||||
const buttonProps = {
|
||||
iconType: 'trash',
|
||||
'aria-label': i18n.DELETE_FILE,
|
||||
color: 'danger' as const,
|
||||
isDisabled: isLoading,
|
||||
onClick: onModalOpen,
|
||||
'data-test-subj': 'cases-files-delete-button',
|
||||
};
|
||||
|
||||
return permissions.delete ? (
|
||||
<>
|
||||
{isIcon ? (
|
||||
<EuiButtonIcon {...buttonProps} />
|
||||
) : (
|
||||
<EuiButtonEmpty {...buttonProps}>{i18n.DELETE_FILE}</EuiButtonEmpty>
|
||||
)}
|
||||
{showDeletionModal ? (
|
||||
<DeleteAttachmentConfirmationModal
|
||||
title={i18n.DELETE_FILE_TITLE}
|
||||
confirmButtonText={i18n.DELETE}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
FileDeleteButtonComponent.displayName = 'FileDeleteButton';
|
||||
|
||||
export const FileDeleteButton = React.memo(FileDeleteButtonComponent);
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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, mockedTestProvidersOwner } from '../../common/mock';
|
||||
import { FileDownloadButton } from './file_download_button';
|
||||
import { basicFileMock } from '../../containers/mock';
|
||||
import { constructFileKindIdByOwner } from '../../../common/files';
|
||||
|
||||
describe('FileDownloadButton', () => {
|
||||
let appMockRender: AppMockRenderer;
|
||||
|
||||
describe('isIcon', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appMockRender = createAppMockRenderer();
|
||||
});
|
||||
|
||||
it('renders download button with correct href', async () => {
|
||||
appMockRender.render(<FileDownloadButton fileId={basicFileMock.id} isIcon={true} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument();
|
||||
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
|
||||
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
|
||||
id: basicFileMock.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('not isIcon', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appMockRender = createAppMockRenderer();
|
||||
});
|
||||
|
||||
it('renders download button with correct href', async () => {
|
||||
appMockRender.render(<FileDownloadButton fileId={basicFileMock.id} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument();
|
||||
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
|
||||
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
|
||||
id: basicFileMock.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
|
||||
import { useFilesContext } from '@kbn/shared-ux-file-context';
|
||||
|
||||
import type { Owner } from '../../../common/constants/types';
|
||||
|
||||
import { constructFileKindIdByOwner } from '../../../common/files';
|
||||
import { useCasesContext } from '../cases_context/use_cases_context';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface FileDownloadButtonProps {
|
||||
fileId: string;
|
||||
isIcon?: boolean;
|
||||
}
|
||||
|
||||
const FileDownloadButtonComponent: React.FC<FileDownloadButtonProps> = ({ fileId, isIcon }) => {
|
||||
const { owner } = useCasesContext();
|
||||
const { client: filesClient } = useFilesContext();
|
||||
|
||||
const buttonProps = {
|
||||
iconType: 'download',
|
||||
'aria-label': i18n.DOWNLOAD_FILE,
|
||||
href: filesClient.getDownloadHref({
|
||||
fileKind: constructFileKindIdByOwner(owner[0] as Owner),
|
||||
id: fileId,
|
||||
}),
|
||||
'data-test-subj': 'cases-files-download-button',
|
||||
};
|
||||
|
||||
return isIcon ? (
|
||||
<EuiButtonIcon {...buttonProps} />
|
||||
) : (
|
||||
<EuiButtonEmpty {...buttonProps}>{i18n.DOWNLOAD_FILE}</EuiButtonEmpty>
|
||||
);
|
||||
};
|
||||
FileDownloadButtonComponent.displayName = 'FileDownloadButton';
|
||||
|
||||
export const FileDownloadButton = React.memo(FileDownloadButtonComponent);
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { FileNameLink } from './file_name_link';
|
||||
import { basicFileMock } from '../../containers/mock';
|
||||
|
||||
describe('FileNameLink', () => {
|
||||
let appMockRender: AppMockRenderer;
|
||||
|
||||
const defaultProps = {
|
||||
file: basicFileMock,
|
||||
showPreview: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appMockRender = createAppMockRenderer();
|
||||
});
|
||||
|
||||
it('renders clickable name if file is image', async () => {
|
||||
appMockRender.render(<FileNameLink {...defaultProps} />);
|
||||
|
||||
const nameLink = await screen.findByTestId('cases-files-name-link');
|
||||
|
||||
expect(nameLink).toBeInTheDocument();
|
||||
|
||||
userEvent.click(nameLink);
|
||||
|
||||
expect(defaultProps.showPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders simple text name if file is not image', async () => {
|
||||
appMockRender.render(
|
||||
<FileNameLink
|
||||
showPreview={defaultProps.showPreview}
|
||||
file={{ ...basicFileMock, mimeType: 'text/csv' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const nameLink = await screen.findByTestId('cases-files-name-text');
|
||||
|
||||
expect(nameLink).toBeInTheDocument();
|
||||
|
||||
userEvent.click(nameLink);
|
||||
|
||||
expect(defaultProps.showPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { EuiLink } from '@elastic/eui';
|
||||
|
||||
import type { FileJSON } from '@kbn/shared-ux-file-types';
|
||||
import * as i18n from './translations';
|
||||
import { isImage } from './utils';
|
||||
|
||||
interface FileNameLinkProps {
|
||||
file: Pick<FileJSON, 'name' | 'extension' | 'mimeType'>;
|
||||
showPreview: () => void;
|
||||
}
|
||||
|
||||
const FileNameLinkComponent: React.FC<FileNameLinkProps> = ({ file, showPreview }) => {
|
||||
let fileName = file.name;
|
||||
|
||||
if (typeof file.extension !== 'undefined') {
|
||||
fileName += `.${file.extension}`;
|
||||
}
|
||||
|
||||
if (isImage(file)) {
|
||||
return (
|
||||
<EuiLink onClick={showPreview} data-test-subj="cases-files-name-link">
|
||||
{fileName}
|
||||
</EuiLink>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span title={i18n.NO_PREVIEW} data-test-subj="cases-files-name-text">
|
||||
{fileName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
FileNameLinkComponent.displayName = 'FileNameLink';
|
||||
|
||||
export const FileNameLink = React.memo(FileNameLinkComponent);
|
|
@ -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 { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import type { AppMockRenderer } from '../../common/mock';
|
||||
|
||||
import { constructFileKindIdByOwner } from '../../../common/files';
|
||||
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 () => {
|
||||
appMockRender.render(<FilePreview closePreview={jest.fn()} selectedFile={basicFileMock} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
|
||||
id: basicFileMock.id,
|
||||
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
|
||||
})
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { useFilesContext } from '@kbn/shared-ux-file-context';
|
||||
|
||||
import type { Owner } from '../../../common/constants/types';
|
||||
|
||||
import { constructFileKindIdByOwner } from '../../../common/files';
|
||||
import { useCasesContext } from '../cases_context/use_cases_context';
|
||||
|
||||
interface FilePreviewProps {
|
||||
closePreview: () => void;
|
||||
selectedFile: Pick<FileJSON, 'id' | 'name'>;
|
||||
}
|
||||
|
||||
const StyledOverlayMask = styled(EuiOverlayMask)`
|
||||
padding-block-end: 0vh !important;
|
||||
|
||||
img {
|
||||
max-height: 85vh;
|
||||
max-width: 85vw;
|
||||
object-fit: contain;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FilePreview = ({ closePreview, selectedFile }: FilePreviewProps) => {
|
||||
const { client: filesClient } = useFilesContext();
|
||||
const { owner } = useCasesContext();
|
||||
|
||||
return (
|
||||
<StyledOverlayMask>
|
||||
<EuiFocusTrap onClickOutside={closePreview}>
|
||||
<EuiImage
|
||||
alt={selectedFile.name}
|
||||
size="original"
|
||||
src={filesClient.getDownloadHref({
|
||||
id: selectedFile.id,
|
||||
fileKind: constructFileKindIdByOwner(owner[0] as Owner),
|
||||
})}
|
||||
data-test-subj="cases-files-image-preview"
|
||||
/>
|
||||
</EuiFocusTrap>
|
||||
</StyledOverlayMask>
|
||||
);
|
||||
};
|
||||
|
||||
FilePreview.displayName = 'FilePreview';
|
187
x-pack/plugins/cases/public/components/files/file_type.test.tsx
Normal file
187
x-pack/plugins/cases/public/components/files/file_type.test.tsx
Normal file
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* 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 { JsonValue } from '@kbn/utility-types';
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import type { ExternalReferenceAttachmentViewProps } from '../../client/attachment_framework/types';
|
||||
import type { AppMockRenderer } from '../../common/mock';
|
||||
|
||||
import { AttachmentActionType } from '../../client/attachment_framework/types';
|
||||
import { FILE_ATTACHMENT_TYPE } from '../../../common/api';
|
||||
import { createAppMockRenderer } from '../../common/mock';
|
||||
import { basicCase, basicFileMock } from '../../containers/mock';
|
||||
import { getFileType } from './file_type';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('getFileType', () => {
|
||||
const fileType = getFileType();
|
||||
|
||||
it('invalid props return blank FileAttachmentViewObject', () => {
|
||||
expect(fileType).toStrictEqual({
|
||||
id: FILE_ATTACHMENT_TYPE,
|
||||
icon: 'document',
|
||||
displayName: 'File Attachment Type',
|
||||
getAttachmentViewObject: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileAttachmentViewObject', () => {
|
||||
let appMockRender: AppMockRenderer;
|
||||
|
||||
const attachmentViewProps = {
|
||||
externalReferenceId: basicFileMock.id,
|
||||
externalReferenceMetadata: { files: [basicFileMock] },
|
||||
caseData: { title: basicCase.title, id: basicCase.id },
|
||||
} as unknown as ExternalReferenceAttachmentViewProps;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('event renders a clickable name if the file is an image', async () => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
|
||||
// @ts-ignore
|
||||
appMockRender.render(fileType.getAttachmentViewObject({ ...attachmentViewProps }).event);
|
||||
|
||||
expect(await screen.findByText('my-super-cool-screenshot.png')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cases-files-image-preview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the name rendered in event opens the file preview', async () => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
|
||||
// @ts-ignore
|
||||
appMockRender.render(fileType.getAttachmentViewObject({ ...attachmentViewProps }).event);
|
||||
|
||||
userEvent.click(await screen.findByText('my-super-cool-screenshot.png'));
|
||||
expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('getActions renders a download button', async () => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
|
||||
const attachmentViewObject = fileType.getAttachmentViewObject({ ...attachmentViewProps });
|
||||
|
||||
expect(attachmentViewObject).not.toBeUndefined();
|
||||
|
||||
// @ts-ignore
|
||||
const actions = attachmentViewObject.getActions();
|
||||
|
||||
expect(actions.length).toBe(2);
|
||||
expect(actions[0]).toStrictEqual({
|
||||
type: AttachmentActionType.CUSTOM,
|
||||
isPrimary: false,
|
||||
label: 'Download File',
|
||||
render: expect.any(Function),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
appMockRender.render(actions[0].render());
|
||||
|
||||
expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('getActions renders a delete button', async () => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
|
||||
const attachmentViewObject = fileType.getAttachmentViewObject({ ...attachmentViewProps });
|
||||
|
||||
expect(attachmentViewObject).not.toBeUndefined();
|
||||
|
||||
// @ts-ignore
|
||||
const actions = attachmentViewObject.getActions();
|
||||
|
||||
expect(actions.length).toBe(2);
|
||||
expect(actions[1]).toStrictEqual({
|
||||
type: AttachmentActionType.CUSTOM,
|
||||
isPrimary: false,
|
||||
label: 'Delete File',
|
||||
render: expect.any(Function),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
appMockRender.render(actions[1].render());
|
||||
|
||||
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the delete button in actions opens deletion modal', async () => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
|
||||
const attachmentViewObject = fileType.getAttachmentViewObject({ ...attachmentViewProps });
|
||||
|
||||
expect(attachmentViewObject).not.toBeUndefined();
|
||||
|
||||
// @ts-ignore
|
||||
const actions = attachmentViewObject.getActions();
|
||||
|
||||
expect(actions.length).toBe(2);
|
||||
expect(actions[1]).toStrictEqual({
|
||||
type: AttachmentActionType.CUSTOM,
|
||||
isPrimary: false,
|
||||
label: 'Delete File',
|
||||
render: expect.any(Function),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
appMockRender.render(actions[1].render());
|
||||
|
||||
const deleteButton = await screen.findByTestId('cases-files-delete-button');
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('empty externalReferenceMetadata returns blank FileAttachmentViewObject', () => {
|
||||
expect(
|
||||
fileType.getAttachmentViewObject({ ...attachmentViewProps, externalReferenceMetadata: {} })
|
||||
).toEqual({
|
||||
event: 'added an unknown file',
|
||||
hideDefaultActions: true,
|
||||
timelineAvatar: 'document',
|
||||
type: 'regular',
|
||||
getActions: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('timelineAvatar is image if file is an image', () => {
|
||||
expect(fileType.getAttachmentViewObject(attachmentViewProps)).toEqual(
|
||||
expect.objectContaining({
|
||||
timelineAvatar: 'image',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('timelineAvatar is document if file is not an image', () => {
|
||||
expect(
|
||||
fileType.getAttachmentViewObject({
|
||||
...attachmentViewProps,
|
||||
externalReferenceMetadata: {
|
||||
files: [{ ...basicFileMock, mimeType: 'text/csv' } as JsonValue],
|
||||
},
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
timelineAvatar: 'document',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('default actions should be hidden', () => {
|
||||
expect(fileType.getAttachmentViewObject(attachmentViewProps)).toEqual(
|
||||
expect.objectContaining({
|
||||
hideDefaultActions: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
106
x-pack/plugins/cases/public/components/files/file_type.tsx
Normal file
106
x-pack/plugins/cases/public/components/files/file_type.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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 {
|
||||
ExternalReferenceAttachmentType,
|
||||
ExternalReferenceAttachmentViewProps,
|
||||
} from '../../client/attachment_framework/types';
|
||||
import type { DownloadableFile } from './types';
|
||||
|
||||
import { AttachmentActionType } from '../../client/attachment_framework/types';
|
||||
import { FILE_ATTACHMENT_TYPE } from '../../../common/api';
|
||||
import { FileDownloadButton } from './file_download_button';
|
||||
import { FileNameLink } from './file_name_link';
|
||||
import { FilePreview } from './file_preview';
|
||||
import * as i18n from './translations';
|
||||
import { isImage, isValidFileExternalReferenceMetadata } from './utils';
|
||||
import { useFilePreview } from './use_file_preview';
|
||||
import { FileDeleteButton } from './file_delete_button';
|
||||
|
||||
interface FileAttachmentEventProps {
|
||||
file: DownloadableFile;
|
||||
}
|
||||
|
||||
const FileAttachmentEvent = ({ file }: FileAttachmentEventProps) => {
|
||||
const { isPreviewVisible, showPreview, closePreview } = useFilePreview();
|
||||
|
||||
return (
|
||||
<>
|
||||
{i18n.ADDED}
|
||||
<FileNameLink file={file} showPreview={showPreview} />
|
||||
{isPreviewVisible && <FilePreview closePreview={closePreview} selectedFile={file} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FileAttachmentEvent.displayName = 'FileAttachmentEvent';
|
||||
|
||||
function getFileDownloadButton(fileId: string) {
|
||||
return <FileDownloadButton fileId={fileId} isIcon={false} />;
|
||||
}
|
||||
|
||||
function getFileDeleteButton(caseId: string, fileId: string) {
|
||||
return <FileDeleteButton caseId={caseId} fileId={fileId} isIcon={false} />;
|
||||
}
|
||||
|
||||
const getFileAttachmentActions = ({ caseId, fileId }: { caseId: string; fileId: string }) => [
|
||||
{
|
||||
type: AttachmentActionType.CUSTOM as const,
|
||||
render: () => getFileDownloadButton(fileId),
|
||||
label: i18n.DOWNLOAD_FILE,
|
||||
isPrimary: false,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.CUSTOM as const,
|
||||
render: () => getFileDeleteButton(caseId, fileId),
|
||||
label: i18n.DELETE_FILE,
|
||||
isPrimary: false,
|
||||
},
|
||||
];
|
||||
|
||||
const getFileAttachmentViewObject = (props: ExternalReferenceAttachmentViewProps) => {
|
||||
const caseId = props.caseData.id;
|
||||
const fileId = props.externalReferenceId;
|
||||
|
||||
if (!isValidFileExternalReferenceMetadata(props.externalReferenceMetadata)) {
|
||||
return {
|
||||
type: 'regular',
|
||||
event: i18n.ADDED_UNKNOWN_FILE,
|
||||
timelineAvatar: 'document',
|
||||
getActions: () => [
|
||||
{
|
||||
type: AttachmentActionType.CUSTOM as const,
|
||||
render: () => getFileDeleteButton(caseId, fileId),
|
||||
label: i18n.DELETE_FILE,
|
||||
isPrimary: false,
|
||||
},
|
||||
],
|
||||
hideDefaultActions: true,
|
||||
};
|
||||
}
|
||||
|
||||
const fileMetadata = props.externalReferenceMetadata.files[0];
|
||||
const file = {
|
||||
id: fileId,
|
||||
...fileMetadata,
|
||||
};
|
||||
|
||||
return {
|
||||
event: <FileAttachmentEvent file={file} />,
|
||||
timelineAvatar: isImage(file) ? 'image' : 'document',
|
||||
getActions: () => getFileAttachmentActions({ caseId, fileId }),
|
||||
hideDefaultActions: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFileType = (): ExternalReferenceAttachmentType => ({
|
||||
id: FILE_ATTACHMENT_TYPE,
|
||||
icon: 'document',
|
||||
displayName: 'File Attachment Type',
|
||||
getAttachmentViewObject: getFileAttachmentViewObject,
|
||||
});
|
|
@ -0,0 +1,233 @@
|
|||
/*
|
||||
* 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/files';
|
||||
import { createAppMockRenderer, 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(<FilesTable {...defaultProps} />);
|
||||
|
||||
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-download-button')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state', async () => {
|
||||
appMockRender.render(<FilesTable {...defaultProps} isLoading={true} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-table-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty table', async () => {
|
||||
appMockRender.render(<FilesTable {...defaultProps} items={[]} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-table-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FileAdd in empty table is clickable', async () => {
|
||||
appMockRender.render(<FilesTable {...defaultProps} items={[]} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-table-empty')).toBeInTheDocument();
|
||||
|
||||
const addFileButton = await screen.findByTestId('cases-files-add');
|
||||
|
||||
expect(addFileButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(addFileButton);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders single result count properly', async () => {
|
||||
const mockPagination = { pageIndex: 0, pageSize: 10, totalItemCount: 1 };
|
||||
appMockRender.render(<FilesTable {...defaultProps} pagination={mockPagination} />);
|
||||
|
||||
expect(await screen.findByTestId('cases-files-table-results-count')).toHaveTextContent(
|
||||
`Showing ${defaultProps.items.length} file`
|
||||
);
|
||||
});
|
||||
|
||||
it('non image rows dont open file preview', async () => {
|
||||
const nonImageFileMock = { ...basicFileMock, mimeType: 'something/else' };
|
||||
|
||||
appMockRender.render(<FilesTable {...defaultProps} items={[nonImageFileMock]} />);
|
||||
|
||||
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(<FilesTable {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<FilesTable
|
||||
{...defaultProps}
|
||||
pagination={mockPagination}
|
||||
items={[
|
||||
{ ...basicFileMock, mimeType: '' },
|
||||
{ ...basicFileMock, mimeType: 'no-slash' },
|
||||
{ ...basicFileMock, mimeType: '/slash-in-the-beginning' },
|
||||
{ ...basicFileMock, mimeType: undefined },
|
||||
{ ...basicFileMock, mimeType: 'application/gzip' },
|
||||
{ ...basicFileMock, mimeType: 'text/csv' },
|
||||
{ ...basicFileMock, mimeType: 'image/tiff' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText('Unknown')).length).toBe(4);
|
||||
expect(await screen.findByText('Compressed')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Text')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('download button renders correctly', async () => {
|
||||
appMockRender.render(<FilesTable {...defaultProps} />);
|
||||
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
|
||||
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
|
||||
id: basicFileMock.id,
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('delete button renders correctly', async () => {
|
||||
appMockRender.render(<FilesTable {...defaultProps} />);
|
||||
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
|
||||
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
|
||||
id: basicFileMock.id,
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking delete button opens deletion modal', async () => {
|
||||
appMockRender.render(<FilesTable {...defaultProps} />);
|
||||
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
|
||||
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
|
||||
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
|
||||
id: basicFileMock.id,
|
||||
});
|
||||
|
||||
const deleteButton = await screen.findByTestId('cases-files-delete-button');
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(deleteButton);
|
||||
|
||||
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('go to next page calls onTableChange with correct values', async () => {
|
||||
const mockPagination = { pageIndex: 0, pageSize: 1, totalItemCount: 2 };
|
||||
|
||||
appMockRender.render(
|
||||
<FilesTable
|
||||
{...defaultProps}
|
||||
pagination={mockPagination}
|
||||
items={[{ ...basicFileMock }, { ...basicFileMock }]}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<FilesTable
|
||||
{...defaultProps}
|
||||
pagination={mockPagination}
|
||||
items={[{ ...basicFileMock }, { ...basicFileMock }]}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(await screen.findByTestId('pagination-button-previous'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onTableChange).toHaveBeenCalledWith({
|
||||
page: { index: mockPagination.pageIndex - 1, size: mockPagination.pageSize },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('changing perPage calls onTableChange with correct values', async () => {
|
||||
appMockRender.render(
|
||||
<FilesTable {...defaultProps} items={[{ ...basicFileMock }, { ...basicFileMock }]} />
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('tablePaginationPopoverButton'));
|
||||
|
||||
const pageSizeOption = screen.getByTestId('tablePagination-50-rows');
|
||||
|
||||
pageSizeOption.style.pointerEvents = 'all';
|
||||
|
||||
userEvent.click(pageSizeOption);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onTableChange).toHaveBeenCalledWith({
|
||||
page: { index: 0, size: 50 },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
83
x-pack/plugins/cases/public/components/files/files_table.tsx
Normal file
83
x-pack/plugins/cases/public/components/files/files_table.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 * as i18n from './translations';
|
||||
import { useFilesTableColumns } from './use_files_table_columns';
|
||||
import { FilePreview } from './file_preview';
|
||||
import { AddFile } from './add_file';
|
||||
import { useFilePreview } from './use_file_preview';
|
||||
|
||||
const EmptyFilesTable = ({ caseId }: { caseId: string }) => (
|
||||
<EuiEmptyPrompt
|
||||
title={<h3>{i18n.NO_FILES}</h3>}
|
||||
data-test-subj="cases-files-table-empty"
|
||||
titleSize="xs"
|
||||
actions={<AddFile caseId={caseId} />}
|
||||
/>
|
||||
);
|
||||
|
||||
EmptyFilesTable.displayName = 'EmptyFilesTable';
|
||||
|
||||
interface FilesTableProps {
|
||||
caseId: string;
|
||||
isLoading: boolean;
|
||||
items: FileJSON[];
|
||||
onChange: EuiBasicTableProps<FileJSON>['onChange'];
|
||||
pagination: Pagination;
|
||||
}
|
||||
|
||||
export const FilesTable = ({ caseId, items, pagination, onChange, isLoading }: FilesTableProps) => {
|
||||
const { isPreviewVisible, showPreview, closePreview } = useFilePreview();
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<FileJSON>();
|
||||
|
||||
const displayPreview = (file: FileJSON) => {
|
||||
setSelectedFile(file);
|
||||
showPreview();
|
||||
};
|
||||
|
||||
const columns = useFilesTableColumns({ caseId, showPreview: displayPreview });
|
||||
|
||||
return isLoading ? (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiLoadingContent data-test-subj="cases-files-table-loading" lines={10} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{pagination.totalItemCount > 0 && (
|
||||
<>
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiText size="xs" color="subdued" data-test-subj="cases-files-table-results-count">
|
||||
{i18n.SHOWING_FILES(items.length)}
|
||||
</EuiText>
|
||||
</>
|
||||
)}
|
||||
<EuiSpacer size="s" />
|
||||
<EuiBasicTable
|
||||
tableCaption={i18n.FILES_TABLE}
|
||||
items={items}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
onChange={onChange}
|
||||
data-test-subj="cases-files-table"
|
||||
noItemsMessage={<EmptyFilesTable caseId={caseId} />}
|
||||
/>
|
||||
{isPreviewVisible && selectedFile !== undefined && (
|
||||
<FilePreview closePreview={closePreview} selectedFile={selectedFile} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FilesTable.displayName = 'FilesTable';
|
|
@ -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(<FilesUtilityBar {...defaultProps} />);
|
||||
|
||||
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(<FilesUtilityBar {...defaultProps} />);
|
||||
|
||||
await userEvent.type(screen.getByTestId('cases-files-search'), 'My search{enter}');
|
||||
expect(defaultProps.onSearch).toBeCalledWith('My search');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 (
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<AddFile caseId={caseId} />
|
||||
<EuiFlexItem grow={false} style={{ minWidth: 400 }}>
|
||||
<EuiFieldSearch
|
||||
fullWidth
|
||||
placeholder={i18n.SEARCH_PLACEHOLDER}
|
||||
onSearch={onSearch}
|
||||
incremental={false}
|
||||
data-test-subj="cases-files-search"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
FilesUtilityBar.displayName = 'FilesUtilityBar';
|
115
x-pack/plugins/cases/public/components/files/translations.tsx
Normal file
115
x-pack/plugins/cases/public/components/files/translations.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 IMAGE_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.imageMimeType', {
|
||||
defaultMessage: 'Image',
|
||||
});
|
||||
|
||||
export const TEXT_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.textMimeType', {
|
||||
defaultMessage: 'Text',
|
||||
});
|
||||
|
||||
export const COMPRESSED_MIME_TYPE = i18n.translate(
|
||||
'xpack.cases.caseView.files.compressedMimeType',
|
||||
{
|
||||
defaultMessage: 'Compressed',
|
||||
}
|
||||
);
|
||||
|
||||
export const PDF_MIME_TYPE = i18n.translate('xpack.cases.caseView.files.pdfMimeType', {
|
||||
defaultMessage: 'PDF',
|
||||
});
|
||||
|
||||
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}}',
|
||||
});
|
||||
|
||||
export const ADDED = i18n.translate('xpack.cases.caseView.files.added', {
|
||||
defaultMessage: 'added ',
|
||||
});
|
||||
|
||||
export const ADDED_UNKNOWN_FILE = i18n.translate('xpack.cases.caseView.files.addedUnknownFile', {
|
||||
defaultMessage: 'added an unknown file',
|
||||
});
|
||||
|
||||
export const DELETE = i18n.translate('xpack.cases.caseView.files.delete', {
|
||||
defaultMessage: 'Delete',
|
||||
});
|
||||
|
||||
export const DELETE_FILE_TITLE = i18n.translate('xpack.cases.caseView.files.deleteThisFile', {
|
||||
defaultMessage: 'Delete this file?',
|
||||
});
|
12
x-pack/plugins/cases/public/components/files/types.ts
Normal file
12
x-pack/plugins/cases/public/components/files/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 * as rt from 'io-ts';
|
||||
|
||||
import type { SingleFileAttachmentMetadataRt } from '../../../common/api';
|
||||
|
||||
export type DownloadableFile = rt.TypeOf<typeof SingleFileAttachmentMetadataRt> & { id: string };
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { act, renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { useFilePreview } from './use_file_preview';
|
||||
|
||||
describe('useFilePreview', () => {
|
||||
it('isPreviewVisible is false by default', () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useFilePreview();
|
||||
});
|
||||
|
||||
expect(result.current.isPreviewVisible).toBeFalsy();
|
||||
});
|
||||
|
||||
it('showPreview sets isPreviewVisible to true', () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useFilePreview();
|
||||
});
|
||||
|
||||
expect(result.current.isPreviewVisible).toBeFalsy();
|
||||
|
||||
act(() => {
|
||||
result.current.showPreview();
|
||||
});
|
||||
|
||||
expect(result.current.isPreviewVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
it('closePreview sets isPreviewVisible to false', () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useFilePreview();
|
||||
});
|
||||
|
||||
expect(result.current.isPreviewVisible).toBeFalsy();
|
||||
|
||||
act(() => {
|
||||
result.current.showPreview();
|
||||
});
|
||||
|
||||
expect(result.current.isPreviewVisible).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
result.current.closePreview();
|
||||
});
|
||||
|
||||
expect(result.current.isPreviewVisible).toBeFalsy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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 { useState } from 'react';
|
||||
|
||||
export const useFilePreview = () => {
|
||||
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
|
||||
|
||||
const closePreview = () => setIsPreviewVisible(false);
|
||||
const showPreview = () => setIsPreviewVisible(true);
|
||||
|
||||
return { isPreviewVisible, showPreview, closePreview };
|
||||
};
|
|
@ -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 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';
|
||||
import { basicCase } from '../../containers/mock';
|
||||
|
||||
describe('useFilesTableColumns', () => {
|
||||
let appMockRender: AppMockRenderer;
|
||||
|
||||
const useFilesTableColumnsProps: FilesTableColumnsProps = {
|
||||
caseId: basicCase.id,
|
||||
showPreview: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appMockRender = createAppMockRenderer();
|
||||
});
|
||||
|
||||
it('return all files table columns correctly', async () => {
|
||||
const { result } = renderHook(() => useFilesTableColumns(useFilesTableColumnsProps), {
|
||||
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 {
|
||||
"description": "Delete File",
|
||||
"isPrimary": true,
|
||||
"name": "Delete",
|
||||
"render": [Function],
|
||||
},
|
||||
],
|
||||
"name": "Actions",
|
||||
"width": "120px",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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 * as i18n from './translations';
|
||||
import { parseMimeType } from './utils';
|
||||
import { FileNameLink } from './file_name_link';
|
||||
import { FileDownloadButton } from './file_download_button';
|
||||
import { FileDeleteButton } from './file_delete_button';
|
||||
|
||||
export interface FilesTableColumnsProps {
|
||||
caseId: string;
|
||||
showPreview: (file: FileJSON) => void;
|
||||
}
|
||||
|
||||
export const useFilesTableColumns = ({
|
||||
caseId,
|
||||
showPreview,
|
||||
}: FilesTableColumnsProps): Array<EuiBasicTableColumn<FileJSON>> => {
|
||||
return [
|
||||
{
|
||||
name: i18n.NAME,
|
||||
'data-test-subj': 'cases-files-table-filename',
|
||||
render: (file: FileJSON) => (
|
||||
<FileNameLink file={file} showPreview={() => showPreview(file)} />
|
||||
),
|
||||
width: '60%',
|
||||
},
|
||||
{
|
||||
name: i18n.TYPE,
|
||||
'data-test-subj': 'cases-files-table-filetype',
|
||||
render: (attachment: FileJSON) => {
|
||||
return <span>{parseMimeType(attachment.mimeType)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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: (file: FileJSON) => <FileDownloadButton fileId={file.id} isIcon={true} />,
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
isPrimary: true,
|
||||
description: i18n.DELETE_FILE,
|
||||
render: (file: FileJSON) => (
|
||||
<FileDeleteButton caseId={caseId} fileId={file.id} isIcon={true} />
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
97
x-pack/plugins/cases/public/components/files/utils.test.tsx
Normal file
97
x-pack/plugins/cases/public/components/files/utils.test.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* 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 { JsonValue } from '@kbn/utility-types';
|
||||
|
||||
import {
|
||||
compressionMimeTypes,
|
||||
imageMimeTypes,
|
||||
pdfMimeTypes,
|
||||
textMimeTypes,
|
||||
} from '../../../common/constants/mime_types';
|
||||
import { basicFileMock } from '../../containers/mock';
|
||||
import { isImage, isValidFileExternalReferenceMetadata, parseMimeType } from './utils';
|
||||
|
||||
describe('isImage', () => {
|
||||
it.each(imageMimeTypes)('should return true for image mime type: %s', (mimeType) => {
|
||||
expect(isImage({ mimeType })).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(textMimeTypes)('should return false for text mime type: %s', (mimeType) => {
|
||||
expect(isImage({ mimeType })).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');
|
||||
});
|
||||
|
||||
it.each(imageMimeTypes)('should return "Image" for image mime type: %s', (mimeType) => {
|
||||
expect(parseMimeType(mimeType)).toBe('Image');
|
||||
});
|
||||
|
||||
it.each(textMimeTypes)('should return "Text" for text mime type: %s', (mimeType) => {
|
||||
expect(parseMimeType(mimeType)).toBe('Text');
|
||||
});
|
||||
|
||||
it.each(compressionMimeTypes)(
|
||||
'should return "Compressed" for image mime type: %s',
|
||||
(mimeType) => {
|
||||
expect(parseMimeType(mimeType)).toBe('Compressed');
|
||||
}
|
||||
);
|
||||
|
||||
it.each(pdfMimeTypes)('should return "Pdf" for text mime type: %s', (mimeType) => {
|
||||
expect(parseMimeType(mimeType)).toBe('PDF');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidFileExternalReferenceMetadata', () => {
|
||||
it('should return false for empty objects', () => {
|
||||
expect(isValidFileExternalReferenceMetadata({})).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if the files property is missing', () => {
|
||||
expect(isValidFileExternalReferenceMetadata({ foo: 'bar' })).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if the files property is not an array', () => {
|
||||
expect(isValidFileExternalReferenceMetadata({ files: 'bar' })).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if files is not an array of file metadata', () => {
|
||||
expect(isValidFileExternalReferenceMetadata({ files: [3] })).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false if files is not an array of file metadata 2', () => {
|
||||
expect(
|
||||
isValidFileExternalReferenceMetadata({ files: [{ name: 'foo', mimeType: 'bar' }] })
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true if the metadata is as expected', () => {
|
||||
expect(
|
||||
isValidFileExternalReferenceMetadata({ files: [basicFileMock as unknown as JsonValue] })
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
61
x-pack/plugins/cases/public/components/files/utils.tsx
Normal file
61
x-pack/plugins/cases/public/components/files/utils.tsx
Normal file
|
@ -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 {
|
||||
CommentRequestExternalReferenceType,
|
||||
FileAttachmentMetadata,
|
||||
} from '../../../common/api';
|
||||
|
||||
import {
|
||||
compressionMimeTypes,
|
||||
imageMimeTypes,
|
||||
textMimeTypes,
|
||||
pdfMimeTypes,
|
||||
} from '../../../common/constants/mime_types';
|
||||
import { FileAttachmentMetadataRt } from '../../../common/api';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const isImage = (file: { mimeType?: string }) => file.mimeType?.startsWith('image/');
|
||||
|
||||
export const parseMimeType = (mimeType: string | undefined) => {
|
||||
if (typeof mimeType === 'undefined') {
|
||||
return i18n.UNKNOWN_MIME_TYPE;
|
||||
}
|
||||
|
||||
if (imageMimeTypes.includes(mimeType)) {
|
||||
return i18n.IMAGE_MIME_TYPE;
|
||||
}
|
||||
|
||||
if (textMimeTypes.includes(mimeType)) {
|
||||
return i18n.TEXT_MIME_TYPE;
|
||||
}
|
||||
|
||||
if (compressionMimeTypes.includes(mimeType)) {
|
||||
return i18n.COMPRESSED_MIME_TYPE;
|
||||
}
|
||||
|
||||
if (pdfMimeTypes.includes(mimeType)) {
|
||||
return i18n.PDF_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);
|
||||
};
|
||||
|
||||
export const isValidFileExternalReferenceMetadata = (
|
||||
externalReferenceMetadata: CommentRequestExternalReferenceType['externalReferenceMetadata']
|
||||
): externalReferenceMetadata is FileAttachmentMetadata => {
|
||||
return (
|
||||
FileAttachmentMetadataRt.is(externalReferenceMetadata) &&
|
||||
externalReferenceMetadata?.files?.length >= 1
|
||||
);
|
||||
};
|
|
@ -9,6 +9,8 @@ import { useCallback } from 'react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
|
||||
import { AttachmentActionType } from '../../../../client/attachment_framework/types';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import {
|
||||
parseCommentString,
|
||||
|
@ -42,6 +44,7 @@ export const useLensOpenVisualization = ({ comment }: { comment: string }) => {
|
|||
actionConfig: !lensVisualization.length
|
||||
? null
|
||||
: {
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'lensApp',
|
||||
label: i18n.translate(
|
||||
'xpack.cases.markdownEditor.plugins.lens.openVisualizationButtonLabel',
|
||||
|
|
|
@ -9,6 +9,9 @@ import React, { useCallback, useState } from 'react';
|
|||
import type { EuiButtonProps } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPopover, EuiButtonIcon, EuiButtonEmpty } from '@elastic/eui';
|
||||
|
||||
import type { AttachmentAction } from '../../client/attachment_framework/types';
|
||||
|
||||
import { AttachmentActionType } from '../../client/attachment_framework/types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export interface PropertyActionButtonProps {
|
||||
|
@ -45,7 +48,7 @@ const PropertyActionButton = React.memo<PropertyActionButtonProps>(
|
|||
PropertyActionButton.displayName = 'PropertyActionButton';
|
||||
|
||||
export interface PropertyActionsProps {
|
||||
propertyActions: PropertyActionButtonProps[];
|
||||
propertyActions: AttachmentAction[];
|
||||
customDataTestSubj?: string;
|
||||
}
|
||||
|
||||
|
@ -93,14 +96,17 @@ export const PropertyActions = React.memo<PropertyActionsProps>(
|
|||
{propertyActions.map((action, key) => (
|
||||
<EuiFlexItem grow={false} key={`${action.label}${key}`}>
|
||||
<span>
|
||||
<PropertyActionButton
|
||||
disabled={action.disabled}
|
||||
iconType={action.iconType}
|
||||
label={action.label}
|
||||
color={action.color}
|
||||
onClick={() => onClosePopover(action.onClick)}
|
||||
customDataTestSubj={customDataTestSubj}
|
||||
/>
|
||||
{(action.type === AttachmentActionType.BUTTON && (
|
||||
<PropertyActionButton
|
||||
disabled={action.disabled}
|
||||
iconType={action.iconType}
|
||||
label={action.label}
|
||||
color={action.color}
|
||||
onClick={() => onClosePopover(action.onClick)}
|
||||
customDataTestSubj={customDataTestSubj}
|
||||
/>
|
||||
)) ||
|
||||
(action.type === AttachmentActionType.CUSTOM && action.render())}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
|
|
|
@ -36,6 +36,7 @@ import { useCaseViewNavigation, useCaseViewParams } from '../../../common/naviga
|
|||
import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry';
|
||||
import { PersistableStateAttachmentTypeRegistry } from '../../../client/attachment_framework/persistable_state_registry';
|
||||
import { userProfiles } from '../../../containers/user_profiles/api.mock';
|
||||
import { AttachmentActionType } from '../../../client/attachment_framework/types';
|
||||
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
jest.mock('../../../common/navigation/hooks');
|
||||
|
@ -849,9 +850,27 @@ describe('createCommentUserActionBuilder', () => {
|
|||
|
||||
const attachment = getExternalReferenceAttachment({
|
||||
getActions: () => [
|
||||
{ label: 'My primary button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{ label: 'My primary 2 button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{ label: 'My primary 3 button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary 2 button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary 3 button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -888,14 +907,75 @@ describe('createCommentUserActionBuilder', () => {
|
|||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows correctly a custom action', async () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
const attachment = getExternalReferenceAttachment({
|
||||
getActions: () => [
|
||||
{
|
||||
type: AttachmentActionType.CUSTOM as const,
|
||||
isPrimary: true,
|
||||
label: 'Test button',
|
||||
render: () => (
|
||||
<button type="button" onClick={onClick} data-test-subj="my-custom-button" />
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
|
||||
externalReferenceAttachmentTypeRegistry.register(attachment);
|
||||
|
||||
const userAction = getExternalReferenceUserAction();
|
||||
const builder = createCommentUserActionBuilder({
|
||||
...builderArgs,
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
caseData: {
|
||||
...builderArgs.caseData,
|
||||
comments: [externalReferenceAttachment],
|
||||
},
|
||||
userAction,
|
||||
});
|
||||
|
||||
const createdUserAction = builder.build();
|
||||
|
||||
appMockRender.render(<EuiCommentList comments={createdUserAction} />);
|
||||
|
||||
const customButton = await screen.findByTestId('my-custom-button');
|
||||
|
||||
expect(customButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(customButton);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows correctly the non visible primary actions', async () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
const attachment = getExternalReferenceAttachment({
|
||||
getActions: () => [
|
||||
{ label: 'My primary button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{ label: 'My primary 2 button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{ label: 'My primary 3 button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{
|
||||
type: AttachmentActionType.BUTTON,
|
||||
label: 'My primary button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON,
|
||||
label: 'My primary 2 button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON,
|
||||
label: 'My primary 3 button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -934,16 +1014,101 @@ describe('createCommentUserActionBuilder', () => {
|
|||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides correctly the default actions', async () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
const attachment = getExternalReferenceAttachment({
|
||||
getActions: () => [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My button',
|
||||
iconType: 'download',
|
||||
onClick,
|
||||
},
|
||||
],
|
||||
hideDefaultActions: true,
|
||||
});
|
||||
|
||||
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
|
||||
externalReferenceAttachmentTypeRegistry.register(attachment);
|
||||
|
||||
const userAction = getExternalReferenceUserAction();
|
||||
const builder = createCommentUserActionBuilder({
|
||||
...builderArgs,
|
||||
externalReferenceAttachmentTypeRegistry,
|
||||
caseData: {
|
||||
...builderArgs.caseData,
|
||||
comments: [externalReferenceAttachment],
|
||||
},
|
||||
userAction,
|
||||
});
|
||||
|
||||
const createdUserAction = builder.build();
|
||||
appMockRender.render(<EuiCommentList comments={createdUserAction} />);
|
||||
|
||||
expect(screen.getByTestId('comment-externalReference-.test')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('My primary button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('property-actions-user-action')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByTestId('property-actions-user-action-ellipses'));
|
||||
|
||||
await waitForEuiPopoverOpen();
|
||||
|
||||
// default "Delete attachment" action
|
||||
expect(screen.queryByTestId('property-actions-user-action-trash')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete attachment')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('My button')).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByText('My button'), undefined, { skipPointerEventsCheck: true });
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows correctly the registered primary actions and non-primary actions', async () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
const attachment = getExternalReferenceAttachment({
|
||||
getActions: () => [
|
||||
{ label: 'My button', iconType: 'trash', onClick },
|
||||
{ label: 'My button 2', iconType: 'download', onClick },
|
||||
{ label: 'My primary button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{ label: 'My primary 2 button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{ label: 'My primary 3 button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My button',
|
||||
iconType: 'trash',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My button 2',
|
||||
iconType: 'download',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary 2 button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary 3 button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -990,7 +1155,13 @@ describe('createCommentUserActionBuilder', () => {
|
|||
|
||||
const attachment = getExternalReferenceAttachment({
|
||||
getActions: () => [
|
||||
{ label: 'My primary button', isPrimary: true, iconType: 'danger', onClick },
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My primary button',
|
||||
isPrimary: true,
|
||||
iconType: 'danger',
|
||||
onClick,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
@ -15,11 +15,17 @@ import React, { Suspense } from 'react';
|
|||
import { memoize, partition } from 'lodash';
|
||||
|
||||
import { EuiCallOut, EuiCode, EuiLoadingSpinner, EuiButtonIcon, EuiFlexItem } from '@elastic/eui';
|
||||
import type { AttachmentType } from '../../../client/attachment_framework/types';
|
||||
|
||||
import type {
|
||||
AttachmentType,
|
||||
AttachmentViewObject,
|
||||
} from '../../../client/attachment_framework/types';
|
||||
|
||||
import { AttachmentActionType } from '../../../client/attachment_framework/types';
|
||||
import { UserActionTimestamp } from '../timestamp';
|
||||
import type { AttachmentTypeRegistry } from '../../../../common/registry';
|
||||
import type { CommentResponse } from '../../../../common/api';
|
||||
import type { UserActionBuilder, UserActionBuilderArgs } from '../types';
|
||||
import { UserActionTimestamp } from '../timestamp';
|
||||
import type { SnakeToCamelCase } from '../../../../common/types';
|
||||
import {
|
||||
ATTACHMENT_NOT_REGISTERED_ERROR,
|
||||
|
@ -44,9 +50,7 @@ type BuilderArgs<C, R> = Pick<
|
|||
/**
|
||||
* Provides a render function for attachment type
|
||||
*/
|
||||
const getAttachmentRenderer = memoize((attachmentType: AttachmentType<unknown>) => {
|
||||
const attachmentViewObject = attachmentType.getAttachmentViewObject();
|
||||
|
||||
const getAttachmentRenderer = memoize((attachmentViewObject: AttachmentViewObject) => {
|
||||
let AttachmentElement: React.ReactElement;
|
||||
|
||||
const renderCallback = (props: object) => {
|
||||
|
@ -108,14 +112,15 @@ export const createRegisteredAttachmentUserActionBuilder = <
|
|||
}
|
||||
|
||||
const attachmentType = registry.get(attachmentTypeId);
|
||||
const renderer = getAttachmentRenderer(attachmentType);
|
||||
|
||||
const attachmentViewObject = attachmentType.getAttachmentViewObject();
|
||||
const props = {
|
||||
...getAttachmentViewProps(),
|
||||
caseData: { id: caseData.id, title: caseData.title },
|
||||
};
|
||||
|
||||
const attachmentViewObject = attachmentType.getAttachmentViewObject(props);
|
||||
|
||||
const renderer = getAttachmentRenderer(attachmentViewObject);
|
||||
const actions = attachmentViewObject.getActions?.(props) ?? [];
|
||||
const [primaryActions, nonPrimaryActions] = partition(actions, 'isPrimary');
|
||||
const visiblePrimaryActions = primaryActions.slice(0, 2);
|
||||
|
@ -133,24 +138,29 @@ export const createRegisteredAttachmentUserActionBuilder = <
|
|||
timelineAvatar: attachmentViewObject.timelineAvatar,
|
||||
actions: (
|
||||
<UserActionContentToolbar id={comment.id}>
|
||||
{visiblePrimaryActions.map((action) => (
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
data-test-subj={`attachment-${attachmentTypeId}-${comment.id}`}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={action.label}
|
||||
iconType={action.iconType}
|
||||
color={action.color ?? 'text'}
|
||||
onClick={action.onClick}
|
||||
data-test-subj={`attachment-${attachmentTypeId}-${comment.id}-${action.iconType}`}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
{visiblePrimaryActions.map(
|
||||
(action) =>
|
||||
(action.type === AttachmentActionType.BUTTON && (
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
data-test-subj={`attachment-${attachmentTypeId}-${comment.id}`}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
aria-label={action.label}
|
||||
iconType={action.iconType}
|
||||
color={action.color ?? 'text'}
|
||||
onClick={action.onClick}
|
||||
data-test-subj={`attachment-${attachmentTypeId}-${comment.id}-${action.iconType}`}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)) ||
|
||||
(action.type === AttachmentActionType.CUSTOM && action.render())
|
||||
)}
|
||||
<RegisteredAttachmentsPropertyActions
|
||||
isLoading={isLoading}
|
||||
onDelete={() => handleDeleteComment(comment.id, DELETE_REGISTERED_ATTACHMENT)}
|
||||
registeredAttachmentActions={[...nonVisiblePrimaryActions, ...nonPrimaryActions]}
|
||||
hideDefaultActions={!!attachmentViewObject.hideDefaultActions}
|
||||
/>
|
||||
</UserActionContentToolbar>
|
||||
),
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AttachmentActionType } from '../../../client/attachment_framework/types';
|
||||
import { useCasesContext } from '../../cases_context/use_cases_context';
|
||||
import { DeleteAttachmentConfirmationModal } from '../delete_attachment_confirmation_modal';
|
||||
import { UserActionPropertyActions } from './property_actions';
|
||||
|
@ -31,8 +33,10 @@ const AlertPropertyActionsComponent: React.FC<Props> = ({ isLoading, totalAlerts
|
|||
...(showRemoveAlertIcon
|
||||
? [
|
||||
{
|
||||
iconType: 'minusInCircle',
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
color: 'danger' as const,
|
||||
disabled: false,
|
||||
iconType: 'minusInCircle',
|
||||
label: i18n.REMOVE_ALERTS(totalAlerts),
|
||||
onClick: onModalOpen,
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event';
|
|||
import type { AppMockRenderer } from '../../../common/mock';
|
||||
import { createAppMockRenderer } from '../../../common/mock';
|
||||
import { UserActionPropertyActions } from './property_actions';
|
||||
import { AttachmentActionType } from '../../../client/attachment_framework/types';
|
||||
|
||||
describe('UserActionPropertyActions', () => {
|
||||
let appMock: AppMockRenderer;
|
||||
|
@ -20,6 +21,7 @@ describe('UserActionPropertyActions', () => {
|
|||
isLoading: false,
|
||||
propertyActions: [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'pencil',
|
||||
label: 'Edit',
|
||||
onClick,
|
||||
|
|
|
@ -7,12 +7,12 @@
|
|||
|
||||
import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import type { PropertyActionButtonProps } from '../../property_actions';
|
||||
import type { AttachmentAction } from '../../../client/attachment_framework/types';
|
||||
import { PropertyActions } from '../../property_actions';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
propertyActions: PropertyActionButtonProps[];
|
||||
propertyActions: AttachmentAction[];
|
||||
customDataTestSubj?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
createAppMockRenderer,
|
||||
} from '../../../common/mock';
|
||||
import { RegisteredAttachmentsPropertyActions } from './registered_attachments_property_actions';
|
||||
import { AttachmentActionType } from '../../../client/attachment_framework/types';
|
||||
|
||||
describe('RegisteredAttachmentsPropertyActions', () => {
|
||||
let appMock: AppMockRenderer;
|
||||
|
@ -24,6 +25,7 @@ describe('RegisteredAttachmentsPropertyActions', () => {
|
|||
isLoading: false,
|
||||
registeredAttachmentActions: [],
|
||||
onDelete: jest.fn(),
|
||||
hideDefaultActions: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -90,6 +92,14 @@ describe('RegisteredAttachmentsPropertyActions', () => {
|
|||
expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the property actions when hideDefaultActions is enabled', async () => {
|
||||
const result = appMock.render(
|
||||
<RegisteredAttachmentsPropertyActions {...props} hideDefaultActions={true} />
|
||||
);
|
||||
|
||||
expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does show the property actions with only delete permissions', async () => {
|
||||
appMock = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() });
|
||||
const result = appMock.render(<RegisteredAttachmentsPropertyActions {...props} />);
|
||||
|
@ -99,7 +109,14 @@ describe('RegisteredAttachmentsPropertyActions', () => {
|
|||
|
||||
it('renders correctly registered attachments', async () => {
|
||||
const onClick = jest.fn();
|
||||
const action = [{ label: 'My button', iconType: 'download', onClick }];
|
||||
const action = [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
label: 'My button',
|
||||
iconType: 'download',
|
||||
onClick,
|
||||
},
|
||||
];
|
||||
|
||||
const result = appMock.render(
|
||||
<RegisteredAttachmentsPropertyActions {...props} registeredAttachmentActions={action} />
|
||||
|
|
|
@ -6,7 +6,10 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import type { AttachmentAction } from '../../../client/attachment_framework/types';
|
||||
|
||||
import { AttachmentActionType } from '../../../client/attachment_framework/types';
|
||||
import { useCasesContext } from '../../cases_context/use_cases_context';
|
||||
import * as i18n from './translations';
|
||||
import { UserActionPropertyActions } from './property_actions';
|
||||
|
@ -17,12 +20,14 @@ interface Props {
|
|||
isLoading: boolean;
|
||||
registeredAttachmentActions: AttachmentAction[];
|
||||
onDelete: () => void;
|
||||
hideDefaultActions: boolean;
|
||||
}
|
||||
|
||||
const RegisteredAttachmentsPropertyActionsComponent: React.FC<Props> = ({
|
||||
isLoading,
|
||||
registeredAttachmentActions,
|
||||
onDelete,
|
||||
hideDefaultActions,
|
||||
}) => {
|
||||
const { permissions } = useCasesContext();
|
||||
const { showDeletionModal, onModalOpen, onConfirm, onCancel } = useDeletePropertyAction({
|
||||
|
@ -30,12 +35,14 @@ const RegisteredAttachmentsPropertyActionsComponent: React.FC<Props> = ({
|
|||
});
|
||||
|
||||
const propertyActions = useMemo(() => {
|
||||
const showTrashIcon = permissions.delete;
|
||||
const showTrashIcon = permissions.delete && !hideDefaultActions;
|
||||
|
||||
return [
|
||||
...(showTrashIcon
|
||||
? [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
disabled: false,
|
||||
iconType: 'trash',
|
||||
color: 'danger' as const,
|
||||
label: i18n.DELETE_ATTACHMENT,
|
||||
|
@ -45,7 +52,7 @@ const RegisteredAttachmentsPropertyActionsComponent: React.FC<Props> = ({
|
|||
: []),
|
||||
...registeredAttachmentActions,
|
||||
];
|
||||
}, [permissions.delete, onModalOpen, registeredAttachmentActions]);
|
||||
}, [permissions.delete, hideDefaultActions, onModalOpen, registeredAttachmentActions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AttachmentActionType } from '../../../client/attachment_framework/types';
|
||||
import { useCasesContext } from '../../cases_context/use_cases_context';
|
||||
import { useLensOpenVisualization } from '../../markdown_editor/plugins/lens/use_lens_open_visualization';
|
||||
import * as i18n from './translations';
|
||||
|
@ -48,6 +50,7 @@ const UserCommentPropertyActionsComponent: React.FC<Props> = ({
|
|||
...(showEditPencilIcon
|
||||
? [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'pencil',
|
||||
label: i18n.EDIT_COMMENT,
|
||||
onClick: onEdit,
|
||||
|
@ -57,6 +60,7 @@ const UserCommentPropertyActionsComponent: React.FC<Props> = ({
|
|||
...(showQuoteIcon
|
||||
? [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'quote',
|
||||
label: i18n.QUOTE,
|
||||
onClick: onQuote,
|
||||
|
@ -66,6 +70,7 @@ const UserCommentPropertyActionsComponent: React.FC<Props> = ({
|
|||
...(showTrashIcon
|
||||
? [
|
||||
{
|
||||
type: AttachmentActionType.BUTTON as const,
|
||||
iconType: 'trash',
|
||||
color: 'danger' as const,
|
||||
label: i18n.DELETE_COMMENT,
|
||||
|
|
|
@ -162,3 +162,13 @@ export const getCaseConnectors = async (
|
|||
|
||||
export const getCaseUsers = async (caseId: string, signal: AbortSignal): Promise<CaseUsers> =>
|
||||
Promise.resolve(getCaseUsersMockResponse());
|
||||
|
||||
export const deleteFileAttachments = async ({
|
||||
caseId,
|
||||
fileIds,
|
||||
signal,
|
||||
}: {
|
||||
caseId: string;
|
||||
fileIds: string[];
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> => Promise.resolve(undefined);
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
INTERNAL_BULK_CREATE_ATTACHMENTS_URL,
|
||||
SECURITY_SOLUTION_OWNER,
|
||||
INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL,
|
||||
INTERNAL_DELETE_FILE_ATTACHMENTS_URL,
|
||||
} from '../../common/constants';
|
||||
|
||||
import {
|
||||
|
@ -37,6 +38,7 @@ import {
|
|||
postComment,
|
||||
getCaseConnectors,
|
||||
getCaseUserActionsStats,
|
||||
deleteFileAttachments,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
|
@ -59,6 +61,7 @@ import {
|
|||
caseUserActionsWithRegisteredAttachmentsSnake,
|
||||
basicPushSnake,
|
||||
getCaseUserActionsStatsResponse,
|
||||
basicFileMock,
|
||||
} from './mock';
|
||||
|
||||
import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases';
|
||||
|
@ -820,6 +823,30 @@ describe('Cases API', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deleteFileAttachments', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('should be called with correct url, method, signal and body', async () => {
|
||||
const resp = await deleteFileAttachments({
|
||||
caseId: basicCaseId,
|
||||
fileIds: [basicFileMock.id],
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
INTERNAL_DELETE_FILE_ATTACHMENTS_URL.replace('{case_id}', basicCaseId),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids: [basicFileMock.id] }),
|
||||
signal: abortCtrl.signal,
|
||||
}
|
||||
);
|
||||
expect(resp).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushCase', () => {
|
||||
const connectorId = 'connectorId';
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ import type {
|
|||
import {
|
||||
CommentType,
|
||||
getCaseCommentsUrl,
|
||||
getCasesDeleteFileAttachmentsUrl,
|
||||
getCaseDetailsUrl,
|
||||
getCaseDetailsMetricsUrl,
|
||||
getCasePushUrl,
|
||||
|
@ -401,6 +402,22 @@ export const createAttachments = async (
|
|||
return convertCaseToCamelCase(decodeCaseResponse(response));
|
||||
};
|
||||
|
||||
export const deleteFileAttachments = async ({
|
||||
caseId,
|
||||
fileIds,
|
||||
signal,
|
||||
}: {
|
||||
caseId: string;
|
||||
fileIds: string[];
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> => {
|
||||
await KibanaServices.get().http.fetch(getCasesDeleteFileAttachmentsUrl(caseId), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids: fileIds }),
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getFeatureIds = async (
|
||||
query: { registrationContext: string[] },
|
||||
signal: AbortSignal
|
||||
|
|
|
@ -23,6 +23,9 @@ 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), 'files', params] as const,
|
||||
caseFileStats: (id: string) => [...casesQueriesKeys.case(id), 'files', 'stats'] as const,
|
||||
caseMetrics: (id: string, features: SingleCaseMetricsFeature[]) =>
|
||||
[...casesQueriesKeys.case(id), 'metrics', features] as const,
|
||||
caseConnectors: (id: string) => [...casesQueriesKeys.case(id), 'connectors'],
|
||||
|
@ -50,4 +53,5 @@ export const casesMutationsKeys = {
|
|||
deleteCases: ['delete-cases'] as const,
|
||||
updateCases: ['update-cases'] as const,
|
||||
deleteComment: ['delete-comment'] as const,
|
||||
deleteFileAttachment: ['delete-file-attachment'] as const,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -21,6 +21,10 @@ export const ERROR_DELETING = i18n.translate('xpack.cases.containers.errorDeleti
|
|||
defaultMessage: 'Error deleting data',
|
||||
});
|
||||
|
||||
export const ERROR_DELETING_FILE = i18n.translate('xpack.cases.containers.errorDeletingFile', {
|
||||
defaultMessage: 'Error deleting file',
|
||||
});
|
||||
|
||||
export const ERROR_UPDATING = i18n.translate('xpack.cases.containers.errorUpdatingTitle', {
|
||||
defaultMessage: 'Error updating data',
|
||||
});
|
||||
|
@ -53,3 +57,7 @@ export const STATUS_CHANGED_TOASTER_TEXT = i18n.translate(
|
|||
defaultMessage: 'Updated the statuses of attached alerts.',
|
||||
}
|
||||
);
|
||||
|
||||
export const FILE_DELETE_SUCCESS = i18n.translate('xpack.cases.containers.deleteSuccess', {
|
||||
defaultMessage: 'File deleted successfully',
|
||||
});
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* 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 { act, renderHook } from '@testing-library/react-hooks';
|
||||
import * as api from './api';
|
||||
import { basicCaseId, basicFileMock } from './mock';
|
||||
import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page';
|
||||
import { useToasts } from '../common/lib/kibana';
|
||||
import type { AppMockRenderer } from '../common/mock';
|
||||
import { createAppMockRenderer } from '../common/mock';
|
||||
import { useDeleteFileAttachment } from './use_delete_file_attachment';
|
||||
|
||||
jest.mock('./api');
|
||||
jest.mock('../common/lib/kibana');
|
||||
jest.mock('../components/case_view/use_on_refresh_case_view_page');
|
||||
|
||||
describe('useDeleteFileAttachment', () => {
|
||||
const addSuccess = jest.fn();
|
||||
const addError = jest.fn();
|
||||
|
||||
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
|
||||
|
||||
let appMockRender: AppMockRenderer;
|
||||
|
||||
beforeEach(() => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls deleteFileAttachment with correct arguments - case', async () => {
|
||||
const spyOnDeleteFileAttachments = jest.spyOn(api, 'deleteFileAttachments');
|
||||
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
fileId: basicFileMock.id,
|
||||
});
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(spyOnDeleteFileAttachments).toHaveBeenCalledWith({
|
||||
caseId: basicCaseId,
|
||||
fileIds: [basicFileMock.id],
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes the case page view', async () => {
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
act(() =>
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
fileId: basicFileMock.id,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(useRefreshCaseViewPage()).toBeCalled();
|
||||
});
|
||||
|
||||
it('shows a success toaster correctly', async () => {
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
act(() =>
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
fileId: basicFileMock.id,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(addSuccess).toHaveBeenCalledWith({
|
||||
title: 'File deleted successfully',
|
||||
className: 'eui-textBreakWord',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets isError when fails to delete a file attachment', async () => {
|
||||
const spyOnDeleteFileAttachments = jest.spyOn(api, 'deleteFileAttachments');
|
||||
spyOnDeleteFileAttachments.mockRejectedValue(new Error('Error'));
|
||||
|
||||
const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
|
||||
act(() =>
|
||||
result.current.mutate({
|
||||
caseId: basicCaseId,
|
||||
fileId: basicFileMock.id,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(spyOnDeleteFileAttachments).toBeCalledWith({
|
||||
caseId: basicCaseId,
|
||||
fileIds: [basicFileMock.id],
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
|
||||
expect(addError).toHaveBeenCalled();
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { useMutation } from '@tanstack/react-query';
|
||||
import { casesMutationsKeys } from './constants';
|
||||
import type { ServerError } from '../types';
|
||||
import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page';
|
||||
import { useCasesToast } from '../common/use_cases_toast';
|
||||
import { deleteFileAttachments } from './api';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface MutationArgs {
|
||||
caseId: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export const useDeleteFileAttachment = () => {
|
||||
const { showErrorToast, showSuccessToast } = useCasesToast();
|
||||
const refreshAttachmentsTable = useRefreshCaseViewPage();
|
||||
|
||||
return useMutation(
|
||||
({ caseId, fileId }: MutationArgs) => {
|
||||
const abortCtrlRef = new AbortController();
|
||||
return deleteFileAttachments({ caseId, fileIds: [fileId], signal: abortCtrlRef.signal });
|
||||
},
|
||||
{
|
||||
mutationKey: casesMutationsKeys.deleteFileAttachment,
|
||||
onSuccess: () => {
|
||||
showSuccessToast(i18n.FILE_DELETE_SUCCESS);
|
||||
refreshAttachmentsTable();
|
||||
},
|
||||
onError: (error: ServerError) => {
|
||||
showErrorToast(error, { title: i18n.ERROR_DELETING_FILE });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export type UseDeleteFileAttachment = ReturnType<typeof useDeleteFileAttachment>;
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 } from '@testing-library/react-hooks';
|
||||
|
||||
import { basicCase } from './mock';
|
||||
|
||||
import type { AppMockRenderer } from '../common/mock';
|
||||
import { mockedTestProvidersOwner, createAppMockRenderer } from '../common/mock';
|
||||
import { useToasts } from '../common/lib/kibana';
|
||||
import { useGetCaseFileStats } from './use_get_case_file_stats';
|
||||
import { constructFileKindIdByOwner } from '../../common/files';
|
||||
|
||||
jest.mock('../common/lib/kibana');
|
||||
|
||||
const hookParams = {
|
||||
caseId: basicCase.id,
|
||||
};
|
||||
|
||||
const expectedCallParams = {
|
||||
kind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
meta: { caseIds: [hookParams.caseId] },
|
||||
};
|
||||
|
||||
describe('useGetCaseFileStats', () => {
|
||||
let appMockRender: AppMockRenderer;
|
||||
|
||||
beforeEach(() => {
|
||||
appMockRender = createAppMockRenderer();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls filesClient.list with correct arguments', async () => {
|
||||
const { waitForNextUpdate } = renderHook(() => useGetCaseFileStats(hookParams), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(appMockRender.getFilesClient().list).toHaveBeenCalledWith(expectedCallParams);
|
||||
});
|
||||
|
||||
it('shows an error toast when filesClient.list throws', async () => {
|
||||
const addError = jest.fn();
|
||||
(useToasts as jest.Mock).mockReturnValue({ addError });
|
||||
|
||||
appMockRender.getFilesClient().list = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
|
||||
const { waitForNextUpdate } = renderHook(() => useGetCaseFileStats(hookParams), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(appMockRender.getFilesClient().list).toHaveBeenCalledWith(expectedCallParams);
|
||||
expect(addError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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 { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import type { FileJSON } from '@kbn/shared-ux-file-types';
|
||||
|
||||
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/files';
|
||||
import { useCasesToast } from '../common/use_cases_toast';
|
||||
import { useCasesContext } from '../components/cases_context/use_cases_context';
|
||||
import { casesQueriesKeys } from './constants';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const getTotalFromFileList = (data: { files: FileJSON[]; total: number }): { total: number } => ({
|
||||
total: data.total,
|
||||
});
|
||||
|
||||
export interface GetCaseFileStatsParams {
|
||||
caseId: string;
|
||||
}
|
||||
|
||||
export const useGetCaseFileStats = ({
|
||||
caseId,
|
||||
}: GetCaseFileStatsParams): UseQueryResult<{ total: number }> => {
|
||||
const { owner } = useCasesContext();
|
||||
const { showErrorToast } = useCasesToast();
|
||||
const { client: filesClient } = useFilesContext();
|
||||
|
||||
return useQuery(
|
||||
casesQueriesKeys.caseFileStats(caseId),
|
||||
() => {
|
||||
return filesClient.list({
|
||||
kind: constructFileKindIdByOwner(owner[0] as Owner),
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
meta: { caseIds: [caseId] },
|
||||
});
|
||||
},
|
||||
{
|
||||
select: getTotalFromFileList,
|
||||
keepPreviousData: true,
|
||||
onError: (error: ServerError) => {
|
||||
showErrorToast(error, { title: i18n.ERROR_TITLE });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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, createAppMockRenderer } from '../common/mock';
|
||||
import { useToasts } from '../common/lib/kibana';
|
||||
import { useGetCaseFiles } from './use_get_case_files';
|
||||
import { constructFileKindIdByOwner } from '../../common/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: { caseIds: [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 });
|
||||
|
||||
appMockRender.getFilesClient().list = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
|
||||
const { waitForNextUpdate } = renderHook(() => useGetCaseFiles(hookParams), {
|
||||
wrapper: appMockRender.AppWrapper,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(appMockRender.getFilesClient().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(appMockRender.getFilesClient().list).toBeCalledWith(expectedCallParams);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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/files';
|
||||
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: { caseIds: [caseId] },
|
||||
});
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
onError: (error: ServerError) => {
|
||||
showErrorToast(error, { title: i18n.ERROR_TITLE });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -20,6 +20,9 @@ const buildFileKind = (config: FilesConfig, owner: Owner): FileKindBrowser => {
|
|||
};
|
||||
};
|
||||
|
||||
export const isRegisteredOwner = (ownerToCheck: string): ownerToCheck is Owner =>
|
||||
OWNERS.includes(ownerToCheck as Owner);
|
||||
|
||||
/**
|
||||
* The file kind definition for interacting with the file service for the UI
|
||||
*/
|
||||
|
|
15
x-pack/plugins/cases/public/internal_attachments/index.ts
Normal file
15
x-pack/plugins/cases/public/internal_attachments/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { ExternalReferenceAttachmentTypeRegistry } from '../client/attachment_framework/external_reference_registry';
|
||||
import { getFileType } from '../components/files/file_type';
|
||||
|
||||
export const registerInternalAttachments = (
|
||||
externalRefRegistry: ExternalReferenceAttachmentTypeRegistry
|
||||
) => {
|
||||
externalRefRegistry.register(getFileType());
|
||||
};
|
|
@ -28,6 +28,7 @@ import { getUICapabilities } from './client/helpers/capabilities';
|
|||
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
|
||||
import { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry';
|
||||
import { registerCaseFileKinds } from './files';
|
||||
import { registerInternalAttachments } from './internal_attachments';
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
@ -53,6 +54,7 @@ export class CasesUiPlugin
|
|||
const externalReferenceAttachmentTypeRegistry = this.externalReferenceAttachmentTypeRegistry;
|
||||
const persistableStateAttachmentTypeRegistry = this.persistableStateAttachmentTypeRegistry;
|
||||
|
||||
registerInternalAttachments(externalReferenceAttachmentTypeRegistry);
|
||||
const config = this.initializerContext.config.get<CasesUiConfigType>();
|
||||
registerCaseFileKinds(config.files, plugins.files);
|
||||
|
||||
|
@ -122,6 +124,7 @@ export class CasesUiPlugin
|
|||
const getCasesContext = getCasesContextLazy({
|
||||
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient: plugins.files.filesClientFactory.asScoped,
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -132,6 +135,7 @@ export class CasesUiPlugin
|
|||
...props,
|
||||
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient: plugins.files.filesClientFactory.asScoped,
|
||||
}),
|
||||
getCasesContext,
|
||||
getRecentCases: (props) =>
|
||||
|
@ -139,6 +143,7 @@ export class CasesUiPlugin
|
|||
...props,
|
||||
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient: plugins.files.filesClientFactory.asScoped,
|
||||
}),
|
||||
// @deprecated Please use the hook useCasesAddToNewCaseFlyout
|
||||
getCreateCaseFlyout: (props) =>
|
||||
|
@ -146,6 +151,7 @@ export class CasesUiPlugin
|
|||
...props,
|
||||
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient: plugins.files.filesClientFactory.asScoped,
|
||||
}),
|
||||
// @deprecated Please use the hook useCasesAddToExistingCaseModal
|
||||
getAllCasesSelectorModal: (props) =>
|
||||
|
@ -153,6 +159,7 @@ export class CasesUiPlugin
|
|||
...props,
|
||||
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
|
||||
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
|
||||
getFilesClient: plugins.files.filesClientFactory.asScoped,
|
||||
}),
|
||||
},
|
||||
hooks: {
|
||||
|
|
|
@ -35,6 +35,7 @@ import type {
|
|||
CasesStatusRequest,
|
||||
CommentRequestAlertType,
|
||||
CommentRequestExternalReferenceNoSOType,
|
||||
CommentRequestExternalReferenceSOType,
|
||||
CommentRequestPersistableStateType,
|
||||
CommentRequestUserType,
|
||||
} from '../common/api';
|
||||
|
@ -167,7 +168,8 @@ export type SupportedCaseAttachment =
|
|||
| CommentRequestAlertType
|
||||
| CommentRequestUserType
|
||||
| CommentRequestPersistableStateType
|
||||
| CommentRequestExternalReferenceNoSOType;
|
||||
| CommentRequestExternalReferenceNoSOType
|
||||
| CommentRequestExternalReferenceSOType;
|
||||
|
||||
export type CaseAttachments = SupportedCaseAttachment[];
|
||||
export type CaseAttachmentWithoutOwner = DistributiveOmit<SupportedCaseAttachment, 'owner'>;
|
||||
|
|
|
@ -39,7 +39,7 @@ export const createFileRequests = ({
|
|||
const files: FileAttachmentMetadata['files'] = [...Array(numFiles).keys()].map((value) => {
|
||||
return {
|
||||
name: `${value}`,
|
||||
createdAt: '2023-02-27T20:26:54.345Z',
|
||||
created: '2023-02-27T20:26:54.345Z',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
};
|
||||
|
|
|
@ -57,8 +57,12 @@
|
|||
"@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",
|
||||
"@kbn/utility-types-jest",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -129,7 +129,7 @@ export const fileMetadata = () => ({
|
|||
name: 'test_file',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
createdAt: '2023-02-27T20:26:54.345Z',
|
||||
created: '2023-02-27T20:26:54.345Z',
|
||||
});
|
||||
|
||||
export const fileAttachmentMetadata: FileAttachmentMetadata = {
|
||||
|
|
|
@ -6,7 +6,10 @@
|
|||
*/
|
||||
|
||||
import { lazy } from 'react';
|
||||
import { ExternalReferenceAttachmentType } from '@kbn/cases-plugin/public/client/attachment_framework/types';
|
||||
import type {
|
||||
AttachmentActionType,
|
||||
ExternalReferenceAttachmentType,
|
||||
} from '@kbn/cases-plugin/public/client/attachment_framework/types';
|
||||
|
||||
const AttachmentContentLazy = lazy(() => import('./external_references_content'));
|
||||
|
||||
|
@ -18,7 +21,13 @@ export const getExternalReferenceAttachmentRegular = (): ExternalReferenceAttach
|
|||
event: 'added a chart',
|
||||
timelineAvatar: 'casesApp',
|
||||
getActions: () => [
|
||||
{ label: 'See attachment', onClick: () => {}, isPrimary: true, iconType: 'arrowRight' },
|
||||
{
|
||||
type: 'button' as AttachmentActionType.BUTTON,
|
||||
label: 'See attachment',
|
||||
onClick: () => {},
|
||||
isPrimary: true,
|
||||
iconType: 'arrowRight',
|
||||
},
|
||||
],
|
||||
children: AttachmentContentLazy,
|
||||
}),
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
import type {
|
||||
AttachmentActionType,
|
||||
PersistableStateAttachmentType,
|
||||
PersistableStateAttachmentViewProps,
|
||||
} from '@kbn/cases-plugin/public/client/attachment_framework/types';
|
||||
|
@ -50,7 +51,13 @@ export const getPersistableStateAttachmentRegular = (
|
|||
event: 'added an embeddable',
|
||||
timelineAvatar: 'casesApp',
|
||||
getActions: () => [
|
||||
{ label: 'See attachment', onClick: () => {}, isPrimary: true, iconType: 'arrowRight' },
|
||||
{
|
||||
type: 'button' as AttachmentActionType.BUTTON,
|
||||
label: 'See attachment',
|
||||
onClick: () => {},
|
||||
isPrimary: true,
|
||||
iconType: 'arrowRight',
|
||||
},
|
||||
],
|
||||
children: getLazyComponent(EmbeddableComponent),
|
||||
}),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue