[Cases] Attach framework registry (#134744)

* Create external reference attachment registry

* Pass externalReferenceAttachmentTypeRegistry to cases client

* Better types

* Show external references user action

* Handle unregistered events

* Add e2e tests

* Fixe fixture plugin naming

* Add cases fixture plugin to tsconfig

* Fix types

* Improvements

* Fix types

* Fixes

* Fix bug

* Add unit test
This commit is contained in:
Christos Nasikas 2022-06-30 22:58:16 +03:00 committed by GitHub
parent 465e6f0abd
commit 65834334d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 974 additions and 50 deletions

View file

@ -417,6 +417,8 @@
"@kbn/watcher-plugin/*": ["x-pack/plugins/watcher/*"],
"@kbn/alerting-fixture-plugin": ["x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts"],
"@kbn/alerting-fixture-plugin/*": ["x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/*"],
"@kbn/cases-fixture-plugin": ["x-pack/test/functional_with_es_ssl/fixtures/plugins/cases"],
"@kbn/cases-fixture-plugin/*": ["x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/*"],
"@kbn/test-feature-usage-plugin": ["x-pack/test/licensing_plugin/plugins/test_feature_usage"],
"@kbn/test-feature-usage-plugin/*": ["x-pack/test/licensing_plugin/plugins/test_feature_usage/*"],
"@kbn/elasticsearch-client-xpack-plugin": ["x-pack/test/plugin_api_integration/plugins/elasticsearch_client"],

View file

@ -21,6 +21,7 @@ import {
CasesStatusResponse,
CasesMetricsResponse,
CaseSeverity,
CommentResponseExternalReferenceType,
} from '../api';
import { SnakeToCamelCase } from '../types';
@ -65,6 +66,7 @@ export type CaseViewRefreshPropInterface = null | {
export type Comment = SnakeToCamelCase<CommentResponse>;
export type AlertComment = SnakeToCamelCase<CommentResponseAlertsType>;
export type ExternalReferenceComment = SnakeToCamelCase<CommentResponseExternalReferenceType>;
export type CaseUserActions = SnakeToCamelCase<CaseUserActionResponse>;
export type CaseExternalService = SnakeToCamelCase<CaseExternalServiceBasic>;
export type Case = Omit<SnakeToCamelCase<CaseResponse>, 'comments'> & { comments: Comment[] };

View file

@ -19,6 +19,7 @@ import {
import { EuiThemeProvider as StyledComponentsThemeProvider } from '@kbn/kibana-react-plugin/common';
import { RenderAppProps } from './types';
import { CasesApp } from './components/app';
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
export const renderApp = (deps: RenderAppProps) => {
const { mountParams } = deps;
@ -31,15 +32,23 @@ export const renderApp = (deps: RenderAppProps) => {
};
};
const CasesAppWithContext = () => {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
interface CasesAppWithContextProps {
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
}
return (
<StyledComponentsThemeProvider darkMode={darkMode}>
<CasesApp />
</StyledComponentsThemeProvider>
);
};
const CasesAppWithContext: React.FC<CasesAppWithContextProps> = React.memo(
({ externalReferenceAttachmentTypeRegistry }) => {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
return (
<StyledComponentsThemeProvider darkMode={darkMode}>
<CasesApp
externalReferenceAttachmentTypeRegistry={externalReferenceAttachmentTypeRegistry}
/>
</StyledComponentsThemeProvider>
);
}
);
CasesAppWithContext.displayName = 'CasesAppWithContext';
@ -60,7 +69,11 @@ export const App: React.FC<{ deps: RenderAppProps }> = ({ deps }) => {
}}
>
<Router history={history}>
<CasesAppWithContext />
<CasesAppWithContext
externalReferenceAttachmentTypeRegistry={
deps.externalReferenceAttachmentTypeRegistry
}
/>
</Router>
</KibanaContextProvider>
</KibanaThemeProvider>

View file

@ -0,0 +1,11 @@
/*
* 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 { AttachmentTypeRegistry } from './registry';
import { ExternalReferenceAttachmentType } from './types';
export class ExternalReferenceAttachmentTypeRegistry extends AttachmentTypeRegistry<ExternalReferenceAttachmentType> {}

View file

@ -0,0 +1,86 @@
/*
* 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 { AttachmentTypeRegistry } from './registry';
export const ExpressionComponent: React.FunctionComponent = () => {
return null;
};
const getItem = (id: string = 'test') => {
return { id };
};
describe('AttachmentTypeRegistry', () => {
beforeEach(() => jest.resetAllMocks());
describe('has()', () => {
it('returns false for unregistered items', () => {
const registry = new AttachmentTypeRegistry();
expect(registry.has('test')).toEqual(false);
});
it('returns true after registering an item', () => {
const registry = new AttachmentTypeRegistry();
registry.register(getItem());
expect(registry.has('test'));
});
});
describe('register()', () => {
it('able to register items', () => {
const registry = new AttachmentTypeRegistry();
registry.register(getItem());
expect(registry.has('test')).toEqual(true);
});
it('throws error if item is already registered', () => {
const registry = new AttachmentTypeRegistry();
registry.register(getItem('test'));
expect(() => registry.register(getItem('test'))).toThrowErrorMatchingInlineSnapshot(
`"Attachment type \\"test\\" is already registered."`
);
});
});
describe('get()', () => {
it('returns item', () => {
const registry = new AttachmentTypeRegistry();
registry.register(getItem());
const actionType = registry.get('test');
expect(actionType).toEqual({
id: 'test',
});
});
it(`throw error when action type doesn't exist`, () => {
const registry = new AttachmentTypeRegistry();
expect(() => registry.get('not-exist-item')).toThrowErrorMatchingInlineSnapshot(
`"Attachment type \\"not-exist-item\\" is not registered."`
);
});
});
describe('list()', () => {
it('returns list of items', () => {
const actionTypeRegistry = new AttachmentTypeRegistry();
actionTypeRegistry.register(getItem());
const actionTypes = actionTypeRegistry.list();
expect(actionTypes).toEqual([
{
id: 'test',
},
]);
});
});
});

View file

@ -0,0 +1,65 @@
/*
* 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';
interface BaseAttachmentType {
id: string;
}
export class AttachmentTypeRegistry<T extends BaseAttachmentType> {
private readonly attachmentTypes: Map<string, T> = new Map();
/**
* Returns true if the attachment type registry has the given type registered
*/
public has(id: string) {
return this.attachmentTypes.has(id);
}
/**
* Registers an attachment type to the type registry
*/
public register(attachmentType: T) {
if (this.has(attachmentType.id)) {
throw new Error(
i18n.translate('xpack.cases.typeRegistry.register.duplicateAttachmentTypeErrorMessage', {
defaultMessage: 'Attachment type "{id}" is already registered.',
values: {
id: attachmentType.id,
},
})
);
}
this.attachmentTypes.set(attachmentType.id, attachmentType);
}
/**
* Returns an attachment type, throw error if not registered
*/
public get(id: string): T {
const attachmentType = this.attachmentTypes.get(id);
if (!attachmentType) {
throw new Error(
i18n.translate('xpack.cases.typeRegistry.get.missingActionTypeErrorMessage', {
defaultMessage: 'Attachment type "{id}" is not registered.',
values: {
id,
},
})
);
}
return attachmentType;
}
public list() {
return Array.from(this.attachmentTypes).map(([id, attachmentType]) => attachmentType);
}
}

View file

@ -0,0 +1,40 @@
/*
* 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 React from 'react';
import { EuiCommentProps, IconType } from '@elastic/eui';
import { CommentRequestExternalReferenceType } from '../../../common/api';
import { Case } from '../../containers/types';
export interface ExternalReferenceAttachmentViewObject {
type?: EuiCommentProps['type'];
timelineIcon?: EuiCommentProps['timelineIcon'];
actions?: EuiCommentProps['actions'];
event?: EuiCommentProps['event'];
children?: React.LazyExoticComponent<React.FC>;
}
export interface ExternalReferenceAttachmentViewProps {
externalReferenceId: CommentRequestExternalReferenceType['externalReferenceId'];
externalReferenceMetadata: CommentRequestExternalReferenceType['externalReferenceMetadata'];
caseData: Pick<Case, 'id' | 'title'>;
}
export interface ExternalReferenceAttachmentType {
id: string;
icon: IconType;
displayName: string;
getAttachmentViewObject: (
props: ExternalReferenceAttachmentViewProps
) => ExternalReferenceAttachmentViewObject;
}
export interface AttachmentFramework {
registerExternalReference: (
externalReferenceAttachmentType: ExternalReferenceAttachmentType
) => void;
}

View file

@ -10,19 +10,24 @@ import { EuiLoadingSpinner } from '@elastic/eui';
import { AllCasesSelectorModalProps } from '../../components/all_cases/selector_modal';
import { CasesProvider, CasesContextProps } from '../../components/cases_context';
export type GetAllCasesSelectorModalProps = AllCasesSelectorModalProps & CasesContextProps;
type GetAllCasesSelectorModalPropsInternal = AllCasesSelectorModalProps & CasesContextProps;
export type GetAllCasesSelectorModalProps = Omit<
GetAllCasesSelectorModalPropsInternal,
'externalReferenceAttachmentTypeRegistry'
>;
const AllCasesSelectorModalLazy: React.FC<AllCasesSelectorModalProps> = lazy(
() => import('../../components/all_cases/selector_modal')
);
export const getAllCasesSelectorModalLazy = ({
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
hiddenStatuses,
onRowClick,
onClose,
}: GetAllCasesSelectorModalProps) => (
<CasesProvider value={{ owner, userCanCrud }}>
}: GetAllCasesSelectorModalPropsInternal) => (
<CasesProvider value={{ externalReferenceAttachmentTypeRegistry, owner, userCanCrud }}>
<Suspense fallback={<EuiLoadingSpinner />}>
<AllCasesSelectorModalLazy
hiddenStatuses={hiddenStatuses}

View file

@ -10,11 +10,13 @@ import React, { lazy, Suspense } from 'react';
import type { CasesProps } from '../../components/app';
import { CasesProvider, CasesContextProps } from '../../components/cases_context';
export type GetCasesProps = CasesProps & CasesContextProps;
type GetCasesPropsInternal = CasesProps & CasesContextProps;
export type GetCasesProps = Omit<GetCasesPropsInternal, 'externalReferenceAttachmentTypeRegistry'>;
const CasesRoutesLazy: React.FC<CasesProps> = lazy(() => import('../../components/app/routes'));
export const getCasesLazy = ({
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
basePath,
@ -27,8 +29,17 @@ export const getCasesLazy = ({
timelineIntegration,
features,
releasePhase,
}: GetCasesProps) => (
<CasesProvider value={{ owner, userCanCrud, basePath, features, releasePhase }}>
}: GetCasesPropsInternal) => (
<CasesProvider
value={{
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
basePath,
features,
releasePhase,
}}
>
<Suspense fallback={<EuiLoadingSpinner />}>
<CasesRoutesLazy
onComponentInitialized={onComponentInitialized}

View file

@ -9,29 +9,62 @@ import { EuiLoadingSpinner } from '@elastic/eui';
import React, { lazy, ReactNode, Suspense } from 'react';
import { CasesContextProps } from '../../components/cases_context';
export type GetCasesContextProps = CasesContextProps;
export type GetCasesContextPropsInternal = CasesContextProps;
export type GetCasesContextProps = Omit<
CasesContextProps,
'externalReferenceAttachmentTypeRegistry'
>;
const CasesProviderLazy: React.FC<{ value: GetCasesContextProps }> = lazy(
const CasesProviderLazy: React.FC<{ value: GetCasesContextPropsInternal }> = lazy(
() => import('../../components/cases_context')
);
const CasesProviderLazyWrapper = ({
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
features,
children,
releasePhase,
}: GetCasesContextProps & { children: ReactNode }) => {
}: GetCasesContextPropsInternal & { children: ReactNode }) => {
return (
<Suspense fallback={<EuiLoadingSpinner />}>
<CasesProviderLazy value={{ owner, userCanCrud, features, releasePhase }}>
<CasesProviderLazy
value={{
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
features,
releasePhase,
}}
>
{children}
</CasesProviderLazy>
</Suspense>
);
};
CasesProviderLazyWrapper.displayName = 'CasesProviderLazyWrapper';
export const getCasesContextLazy = () => {
return CasesProviderLazyWrapper;
export const getCasesContextLazy = ({
externalReferenceAttachmentTypeRegistry,
}: Pick<
GetCasesContextPropsInternal,
'externalReferenceAttachmentTypeRegistry'
>): (() => React.FC<GetCasesContextProps>) => {
const CasesProviderLazyWrapperWithRegistry: React.FC<GetCasesContextProps> = ({
children,
...props
}) => (
<CasesProviderLazyWrapper
{...props}
externalReferenceAttachmentTypeRegistry={externalReferenceAttachmentTypeRegistry}
>
{children}
</CasesProviderLazyWrapper>
);
CasesProviderLazyWrapperWithRegistry.displayName = 'CasesProviderLazyWrapperWithRegistry';
return () => CasesProviderLazyWrapperWithRegistry;
};

View file

@ -10,12 +10,17 @@ import { EuiLoadingSpinner } from '@elastic/eui';
import type { CreateCaseFlyoutProps } from '../../components/create/flyout';
import { CasesProvider, CasesContextProps } from '../../components/cases_context';
export type GetCreateCaseFlyoutProps = CreateCaseFlyoutProps & CasesContextProps;
type GetCreateCaseFlyoutPropsInternal = CreateCaseFlyoutProps & CasesContextProps;
export type GetCreateCaseFlyoutProps = Omit<
GetCreateCaseFlyoutPropsInternal,
'externalReferenceAttachmentTypeRegistry'
>;
export const CreateCaseFlyoutLazy: React.FC<CreateCaseFlyoutProps> = lazy(
() => import('../../components/create/flyout')
);
export const getCreateCaseFlyoutLazy = ({
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
features,
@ -23,8 +28,8 @@ export const getCreateCaseFlyoutLazy = ({
onClose,
onSuccess,
attachments,
}: GetCreateCaseFlyoutProps) => (
<CasesProvider value={{ owner, userCanCrud, features }}>
}: GetCreateCaseFlyoutPropsInternal) => (
<CasesProvider value={{ externalReferenceAttachmentTypeRegistry, owner, userCanCrud, features }}>
<Suspense fallback={<EuiLoadingSpinner />}>
<CreateCaseFlyoutLazy
afterCaseCreated={afterCaseCreated}

View file

@ -10,13 +10,22 @@ import React, { lazy, Suspense } from 'react';
import { CasesProvider, CasesContextProps } from '../../components/cases_context';
import { RecentCasesProps } from '../../components/recent_cases';
export type GetRecentCasesProps = RecentCasesProps & CasesContextProps;
type GetRecentCasesPropsInternal = RecentCasesProps & CasesContextProps;
export type GetRecentCasesProps = Omit<
GetRecentCasesPropsInternal,
'externalReferenceAttachmentTypeRegistry'
>;
const RecentCasesLazy: React.FC<RecentCasesProps> = lazy(
() => import('../../components/recent_cases')
);
export const getRecentCasesLazy = ({ owner, userCanCrud, maxCasesToShow }: GetRecentCasesProps) => (
<CasesProvider value={{ owner, userCanCrud }}>
export const getRecentCasesLazy = ({
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
maxCasesToShow,
}: GetRecentCasesPropsInternal) => (
<CasesProvider value={{ externalReferenceAttachmentTypeRegistry, owner, userCanCrud }}>
<Suspense fallback={<EuiLoadingSpinner />}>
<RecentCasesLazy maxCasesToShow={maxCasesToShow} />
</Suspense>

View file

@ -23,6 +23,9 @@ import {
import { FieldHook } from '../shared_imports';
import { StartServices } from '../../types';
import { ReleasePhase } from '../../components/types';
import { AttachmentTypeRegistry } from '../../client/attachment_framework/registry';
import { ExternalReferenceAttachmentType } from '../../client/attachment_framework/types';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
interface TestProviderProps {
children: React.ReactNode;
@ -30,6 +33,7 @@ interface TestProviderProps {
features?: CasesFeatures;
owner?: string[];
releasePhase?: ReleasePhase;
externalReferenceAttachmentTypeRegistry?: AttachmentTypeRegistry<ExternalReferenceAttachmentType>;
}
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
@ -43,6 +47,7 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
owner = [SECURITY_SOLUTION_OWNER],
userCanCrud = true,
releasePhase = 'ga',
externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(),
}) => {
const queryClient = new QueryClient({
defaultOptions: {
@ -57,7 +62,11 @@ const TestProvidersComponent: React.FC<TestProviderProps> = ({
<MockKibanaContextProvider>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<QueryClientProvider client={queryClient}>
<CasesProvider value={{ features, owner, userCanCrud }}>{children}</CasesProvider>
<CasesProvider
value={{ externalReferenceAttachmentTypeRegistry, features, owner, userCanCrud }}
>
{children}
</CasesProvider>
</QueryClientProvider>
</ThemeProvider>
</MockKibanaContextProvider>
@ -69,6 +78,7 @@ TestProvidersComponent.displayName = 'TestProviders';
export const TestProviders = React.memo(TestProvidersComponent);
export interface AppMockRenderer {
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
render: UiRender;
coreStart: StartServices;
queryClient: QueryClient;
@ -87,6 +97,7 @@ export const createAppMockRenderer = ({
owner = [SECURITY_SOLUTION_OWNER],
userCanCrud = true,
releasePhase = 'ga',
externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry(),
}: Omit<TestProviderProps, 'children'> = {}): AppMockRenderer => {
const services = createStartServicesMock();
const queryClient = new QueryClient({
@ -102,7 +113,15 @@ export const createAppMockRenderer = ({
<KibanaContextProvider services={services}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<QueryClientProvider client={queryClient}>
<CasesProvider value={{ features, owner, userCanCrud, releasePhase }}>
<CasesProvider
value={{
externalReferenceAttachmentTypeRegistry,
features,
owner,
userCanCrud,
releasePhase,
}}
>
{children}
</CasesProvider>
</QueryClientProvider>
@ -110,18 +129,22 @@ export const createAppMockRenderer = ({
</KibanaContextProvider>
</I18nProvider>
);
AppWrapper.displayName = 'AppWrapper';
const render: UiRender = (ui, options) => {
return reactRender(ui, {
wrapper: AppWrapper,
...options,
});
};
return {
coreStart: services,
queryClient,
render,
AppWrapper,
externalReferenceAttachmentTypeRegistry,
};
};

View file

@ -17,6 +17,7 @@ import { alertComment } from '../../../containers/mock';
import { useCreateAttachments } from '../../../containers/use_create_attachments';
import { CasesContext } from '../../cases_context';
import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer';
import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry';
import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal';
jest.mock('../../../common/use_cases_toast');
@ -45,6 +46,8 @@ const TestComponent: React.FC = () => {
const useCreateAttachmentsMock = useCreateAttachments as jest.Mock;
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
describe('use cases add to existing case modal hook', () => {
useCreateAttachmentsMock.mockReturnValue({
createAttachments: jest.fn(),
@ -56,6 +59,7 @@ describe('use cases add to existing case modal hook', () => {
return (
<CasesContext.Provider
value={{
externalReferenceAttachmentTypeRegistry,
owner: ['test'],
userCanCrud: true,
appId: 'test',

View file

@ -7,6 +7,7 @@
import React from 'react';
import { APP_OWNER } from '../../../common/constants';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import { getCasesLazy } from '../../client/ui/get_cases';
import { useApplicationCapabilities } from '../../common/lib/kibana';
@ -15,12 +16,19 @@ import { CasesRoutesProps } from './types';
export type CasesProps = CasesRoutesProps;
const CasesAppComponent: React.FC = () => {
interface CasesAppProps {
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
}
const CasesAppComponent: React.FC<CasesAppProps> = ({
externalReferenceAttachmentTypeRegistry,
}) => {
const userCapabilities = useApplicationCapabilities();
return (
<Wrapper data-test-subj="cases-app">
{getCasesLazy({
externalReferenceAttachmentTypeRegistry,
owner: [APP_OWNER],
useFetchAlertData: () => [false, {}],
userCanCrud: userCapabilities.generalCases.crud,

View file

@ -18,10 +18,12 @@ import {
import { CasesFeaturesAllRequired, CasesFeatures } from '../../containers/types';
import { CasesGlobalComponents } from './cases_global_components';
import { ReleasePhase } from '../types';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
export type CasesContextValueDispatch = Dispatch<CasesContextStoreAction>;
export interface CasesContextValue {
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
owner: string[];
appId: string;
appTitle: string;
@ -32,7 +34,11 @@ export interface CasesContextValue {
dispatch: CasesContextValueDispatch;
}
export interface CasesContextProps extends Pick<CasesContextValue, 'owner' | 'userCanCrud'> {
export interface CasesContextProps
extends Pick<
CasesContextValue,
'owner' | 'userCanCrud' | 'externalReferenceAttachmentTypeRegistry'
> {
basePath?: string;
features?: CasesFeatures;
releasePhase?: ReleasePhase;
@ -47,11 +53,19 @@ export interface CasesContextStateValue extends Omit<CasesContextValue, 'appId'
export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({
children,
value: { owner, userCanCrud, basePath = DEFAULT_BASE_PATH, features = {}, releasePhase = 'ga' },
value: {
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
basePath = DEFAULT_BASE_PATH,
features = {},
releasePhase = 'ga',
},
}) => {
const { appId, appTitle } = useApplication();
const [state, dispatch] = useReducer(casesContextReducer, getInitialCasesContextState());
const [value, setValue] = useState<CasesContextStateValue>(() => ({
externalReferenceAttachmentTypeRegistry,
owner,
userCanCrud,
basePath,

View file

@ -13,8 +13,12 @@ import React from 'react';
import { CasesContext } from '../../cases_context';
import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer';
import { useCasesAddToNewCaseFlyout } from './use_cases_add_to_new_case_flyout';
import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry';
jest.mock('../../../common/use_cases_toast');
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
describe('use cases add to new case flyout hook', () => {
const dispatch = jest.fn();
let wrapper: React.FC;
@ -24,6 +28,7 @@ describe('use cases add to new case flyout hook', () => {
return (
<CasesContext.Provider
value={{
externalReferenceAttachmentTypeRegistry,
owner: ['test'],
userCanCrud: true,
appId: 'test',

View file

@ -34,6 +34,9 @@ exports[`EditableTitle renders 1`] = `
<CasesProvider
value={
Object {
"externalReferenceAttachmentTypeRegistry": ExternalReferenceAttachmentTypeRegistry {
"attachmentTypes": Map {},
},
"features": undefined,
"owner": Array [
"securitySolution",

View file

@ -34,6 +34,9 @@ exports[`HeaderPage it renders 1`] = `
<CasesProvider
value={
Object {
"externalReferenceAttachmentTypeRegistry": ExternalReferenceAttachmentTypeRegistry {
"attachmentTypes": Map {},
},
"features": undefined,
"owner": Array [
"securitySolution",

View file

@ -13,15 +13,19 @@ import { Actions } from '../../../../common/api';
import {
alertComment,
basicCase,
externalReferenceAttachment,
getAlertUserAction,
getExternalReferenceAttachment,
getExternalReferenceUserAction,
getHostIsolationUserAction,
getUserAction,
hostIsolationComment,
} from '../../../containers/mock';
import { TestProviders } from '../../../common/mock';
import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../../common/mock';
import { createCommentUserActionBuilder } from './comment';
import { getMockBuilderArgs } from '../mock';
import { useCaseViewParams } from '../../../common/navigation';
import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry';
jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/navigation/hooks');
@ -172,4 +176,91 @@ describe('createCommentUserActionBuilder', () => {
expect(screen.getByText('host1')).toBeInTheDocument();
expect(screen.getByText('I just isolated the host!')).toBeInTheDocument();
});
describe('External references', () => {
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
});
it('renders correctly an external reference', async () => {
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
externalReferenceAttachmentTypeRegistry.register(
getExternalReferenceAttachment({ type: 'regular' })
);
const userAction = getExternalReferenceUserAction();
const builder = createCommentUserActionBuilder({
...builderArgs,
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
userAction,
});
const createdUserAction = builder.build();
const result = appMockRender.render(<EuiCommentList comments={createdUserAction} />);
expect(result.getByTestId('comment-external-reference-.test')).toBeInTheDocument();
expect(result.getByTestId('copy-link-external-reference-comment-id')).toBeInTheDocument();
expect(result.getByTestId('user-action-username-with-avatar')).toBeInTheDocument();
expect(screen.getByText('added a chart')).toBeInTheDocument();
});
it('renders correctly if the reference is not registered', async () => {
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
const userAction = getExternalReferenceUserAction();
const builder = createCommentUserActionBuilder({
...builderArgs,
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
userAction,
});
const createdUserAction = builder.build();
const result = appMockRender.render(<EuiCommentList comments={createdUserAction} />);
expect(result.getByTestId('comment-external-reference-not-found')).toBeInTheDocument();
expect(screen.getByText('added an attachment of type')).toBeInTheDocument();
expect(screen.getByText('Attachment type is not registered')).toBeInTheDocument();
});
it('renders correctly an external reference with actions', async () => {
const ActionsView = () => {
return <>{'Attachment actions'}</>;
};
const attachment = getExternalReferenceAttachment({
type: 'regular',
actions: <ActionsView />,
});
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();
const result = appMockRender.render(<EuiCommentList comments={createdUserAction} />);
expect(result.getByTestId('comment-external-reference-.test')).toBeInTheDocument();
expect(screen.getByText('Attachment actions')).toBeInTheDocument();
});
});
});

View file

@ -15,6 +15,7 @@ import * as i18n from '../translations';
import { createUserAttachmentUserActionBuilder } from './user';
import { createAlertAttachmentUserActionBuilder } from './alert';
import { createActionAttachmentUserActionBuilder } from './actions';
import { createExternalReferenceAttachmentUserActionBuilder } from './external_reference';
const getUpdateLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`;
const getDeleteLabelTitle = () => `${i18n.REMOVED_FIELD} ${i18n.COMMENT.toLowerCase()}`;
@ -38,6 +39,8 @@ const getDeleteCommentUserAction = ({
const getCreateCommentUserAction = ({
userAction,
caseData,
externalReferenceAttachmentTypeRegistry,
comment,
userCanCrud,
commentRefs,
@ -59,7 +62,7 @@ const getCreateCommentUserAction = ({
comment: Comment;
} & Omit<
UserActionBuilderArgs,
'caseData' | 'caseServices' | 'comments' | 'index' | 'handleOutlineComment'
'caseServices' | 'comments' | 'index' | 'handleOutlineComment'
>): EuiCommentProps[] => {
switch (comment.type) {
case CommentType.user:
@ -96,6 +99,14 @@ const getCreateCommentUserAction = ({
actionsNavigation,
});
return actionBuilder.build();
case CommentType.externalReference:
const externalReferenceBuilder = createExternalReferenceAttachmentUserActionBuilder({
userAction,
comment,
externalReferenceAttachmentTypeRegistry,
caseData,
});
return externalReferenceBuilder.build();
default:
return [];
}
@ -103,6 +114,7 @@ const getCreateCommentUserAction = ({
export const createCommentUserActionBuilder: UserActionBuilder = ({
caseData,
externalReferenceAttachmentTypeRegistry,
userAction,
userCanCrud,
commentRefs,
@ -129,13 +141,16 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({
}
const comment = caseData.comments.find((c) => c.id === commentUserAction.commentId);
if (comment == null) {
return [];
}
if (commentUserAction.action === Actions.create) {
const commentAction = getCreateCommentUserAction({
caseData,
userAction: commentUserAction,
externalReferenceAttachmentTypeRegistry,
comment,
userCanCrud,
commentRefs,

View file

@ -0,0 +1,102 @@
/*
* 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, { Suspense } from 'react';
import { EuiCallOut, EuiCode, EuiLoadingSpinner } from '@elastic/eui';
import { CommentResponseExternalReferenceType } from '../../../../common/api';
import { UserActionBuilder, UserActionBuilderArgs } from '../types';
import { UserActionTimestamp } from '../timestamp';
import { SnakeToCamelCase } from '../../../../common/types';
import { UserActionUsernameWithAvatar } from '../avatar_username';
import { UserActionCopyLink } from '../copy_link';
import { ATTACHMENT_NOT_REGISTERED_ERROR, DEFAULT_EVENT_ATTACHMENT_TITLE } from './translations';
type BuilderArgs = Pick<
UserActionBuilderArgs,
'userAction' | 'externalReferenceAttachmentTypeRegistry' | 'caseData'
> & {
comment: SnakeToCamelCase<CommentResponseExternalReferenceType>;
};
export const createExternalReferenceAttachmentUserActionBuilder = ({
userAction,
comment,
externalReferenceAttachmentTypeRegistry,
caseData,
}: BuilderArgs): ReturnType<UserActionBuilder> => ({
// TODO: Fix this manually. Issue #123375
// eslint-disable-next-line react/display-name
build: () => {
const isTypeRegistered = externalReferenceAttachmentTypeRegistry.has(
comment.externalReferenceAttachmentTypeId
);
if (!isTypeRegistered) {
return [
{
username: (
<UserActionUsernameWithAvatar
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
event: (
<>
{`${DEFAULT_EVENT_ATTACHMENT_TITLE} `}
<EuiCode>{comment.externalReferenceAttachmentTypeId}</EuiCode>
</>
),
className: 'comment-external-reference-not-found',
'data-test-subj': 'comment-external-reference-not-found',
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
children: (
<EuiCallOut title={ATTACHMENT_NOT_REGISTERED_ERROR} color="danger" iconType="alert" />
),
},
];
}
const externalReferenceType = externalReferenceAttachmentTypeRegistry.get(
comment.externalReferenceAttachmentTypeId
);
const externalReferenceViewObject = externalReferenceType.getAttachmentViewObject({
externalReferenceId: comment.externalReferenceId,
externalReferenceMetadata: comment.externalReferenceMetadata,
caseData: { id: caseData.id, title: caseData.title },
});
return [
{
username: (
<UserActionUsernameWithAvatar
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
type: externalReferenceViewObject.type,
className: `comment-external-reference${comment.externalReferenceAttachmentTypeId}`,
event: externalReferenceViewObject.event,
'data-test-subj': `comment-external-reference-${comment.externalReferenceAttachmentTypeId}`,
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineIcon: externalReferenceViewObject.timelineIcon,
actions: (
<>
<UserActionCopyLink id={comment.id} />
{externalReferenceViewObject.actions}
</>
),
children: externalReferenceViewObject.children ? (
<Suspense fallback={<EuiLoadingSpinner />}>
{React.createElement(externalReferenceViewObject.children)}
</Suspense>
) : undefined,
},
];
},
});

View file

@ -0,0 +1,22 @@
/*
* 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 ATTACHMENT_NOT_REGISTERED_ERROR = i18n.translate(
'xpack.cases.userActions.attachmentNotRegisteredErrorMsg',
{
defaultMessage: 'Attachment type is not registered',
}
);
export const DEFAULT_EVENT_ATTACHMENT_TITLE = i18n.translate(
'xpack.cases.userActions.defaultEventAttachmentTitle',
{
defaultMessage: 'added an attachment of type',
}
);

View file

@ -27,6 +27,7 @@ import type { UserActionTreeProps } from './types';
import { getDescriptionUserAction } from './description';
import { useUserActionsHandler } from './use_user_actions_handler';
import { NEW_COMMENT_ID } from './constants';
import { useCasesContext } from '../cases_context/use_cases_context';
const MyEuiFlexGroup = styled(EuiFlexGroup)`
margin-bottom: 8px;
@ -95,6 +96,7 @@ export const UserActions = React.memo(
const { detailName: caseId, commentId } = useCaseViewParams();
const [initLoading, setInitLoading] = useState(true);
const currentUser = useCurrentUser();
const { externalReferenceAttachmentTypeRegistry } = useCasesContext();
const alertIdsWithoutRuleInfo = useMemo(
() => getManualAlertIdsWithNoRuleId(caseData.comments),
@ -188,6 +190,7 @@ export const UserActions = React.memo(
const userActionBuilder = builder({
caseData,
externalReferenceAttachmentTypeRegistry,
userAction,
caseServices,
comments: caseData.comments,
@ -215,6 +218,7 @@ export const UserActions = React.memo(
),
[
caseUserActions,
externalReferenceAttachmentTypeRegistry,
descriptionCommentListObj,
caseData,
caseServices,

View file

@ -7,6 +7,7 @@
import { Actions } from '../../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import { basicCase, basicPush, getUserAction } from '../../containers/mock';
import { UserActionBuilderArgs } from './types';
@ -56,9 +57,11 @@ export const getMockBuilderArgs = (): UserActionBuilderArgs => {
const handleDeleteComment = jest.fn();
const handleManageQuote = jest.fn();
const handleOutlineComment = jest.fn();
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
return {
userAction,
externalReferenceAttachmentTypeRegistry,
caseData: basicCase,
comments: basicCase.comments,
caseServices,

View file

@ -15,6 +15,7 @@ import { UserActionMarkdownRefObject } from './markdown_form';
import { CasesNavigation } from '../links';
import { UNSUPPORTED_ACTION_TYPES } from './constants';
import type { OnUpdateFields } from '../case_view/types';
import { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
export interface UserActionTreeProps {
caseServices: CaseServices;
@ -37,6 +38,7 @@ export type SupportedUserActionTypes = keyof Omit<typeof ActionTypes, Unsupporte
export interface UserActionBuilderArgs {
caseData: Case;
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
userAction: CaseUserActions;
caseServices: CaseServices;
comments: Comment[];

View file

@ -13,6 +13,7 @@ import type {
SingleCaseMetricsFeature,
AlertComment,
CasesMetrics,
ExternalReferenceComment,
} from '../../common/ui/types';
import {
Actions,
@ -33,10 +34,15 @@ import {
UserActionWithResponse,
CommentUserAction,
CaseSeverity,
ExternalReferenceStorageType,
} from '../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../common/constants';
import { SnakeToCamelCase } from '../../common/types';
import { covertToSnakeCase } from './utils';
import {
ExternalReferenceAttachmentType,
ExternalReferenceAttachmentViewObject,
} from '../client/attachment_framework/types';
export { connectorsMock } from '../common/mock/connectors';
export const basicCaseId = 'basic-case-id';
@ -159,6 +165,23 @@ export const hostReleaseComment: () => Comment = () => {
};
};
export const externalReferenceAttachment: ExternalReferenceComment = {
type: CommentType.externalReference,
id: 'external-reference-comment-id',
externalReferenceId: 'my-id',
externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc },
externalReferenceAttachmentTypeId: '.test',
externalReferenceMetadata: null,
createdAt: basicCreatedAt,
createdBy: elasticUser,
owner: SECURITY_SOLUTION_OWNER,
pushedAt: null,
pushedBy: null,
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFc',
};
export const basicCase: Case = {
owner: SECURITY_SOLUTION_OWNER,
closedAt: null,
@ -691,3 +714,36 @@ export const basicCaseClosed: Case = {
closedBy: elasticUser,
status: CaseStatuses.closed,
};
export const getExternalReferenceUserAction = (): SnakeToCamelCase<
UserActionWithResponse<CommentUserAction>
> => ({
...getUserAction(ActionTypes.comment, Actions.create),
actionId: 'external-reference-action-id',
type: ActionTypes.comment,
commentId: 'external-reference-comment-id',
payload: {
comment: {
type: CommentType.externalReference,
externalReferenceId: 'my-id',
externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc },
externalReferenceAttachmentTypeId: '.test',
externalReferenceMetadata: null,
owner: SECURITY_SOLUTION_OWNER,
},
},
});
export const getExternalReferenceAttachment = (
viewObject: ExternalReferenceAttachmentViewObject = {}
): ExternalReferenceAttachmentType => ({
id: '.test',
icon: 'casesApp',
displayName: 'Test',
getAttachmentViewObject: () => ({
type: 'update',
event: 'added a chart',
timelineIcon: 'casesApp',
...viewObject,
}),
});

View file

@ -8,7 +8,7 @@
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { CasesUiStart, CasesPluginSetup, CasesPluginStart } from './types';
import { CasesUiStart, CasesPluginSetup, CasesPluginStart, CasesUiSetup } from './types';
import { KibanaServices } from './common/lib/kibana';
import { CasesUiConfigType } from '../common/ui/types';
import { APP_ID, APP_PATH } from '../common/constants';
@ -24,6 +24,7 @@ import { getCasesContextLazy } from './client/ui/get_cases_context';
import { getCreateCaseFlyoutLazy } from './client/ui/get_create_case_flyout';
import { getRecentCasesLazy } from './client/ui/get_recent_cases';
import { groupAlertsByRule } from './client/helpers/group_alerts_by_rule';
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
/**
* @public
@ -34,14 +35,17 @@ export class CasesUiPlugin
{
private readonly kibanaVersion: string;
private readonly storage = new Storage(localStorage);
private externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.kibanaVersion = initializerContext.env.packageInfo.version;
this.externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
}
public setup(core: CoreSetup, plugins: CasesPluginSetup) {
public setup(core: CoreSetup, plugins: CasesPluginSetup): CasesUiSetup {
const kibanaVersion = this.kibanaVersion;
const storage = this.storage;
const externalReferenceAttachmentTypeRegistry = this.externalReferenceAttachmentTypeRegistry;
if (plugins.home) {
plugins.home.featureCatalogue.register({
@ -74,27 +78,58 @@ export class CasesUiPlugin
pluginsStart,
storage,
kibanaVersion,
externalReferenceAttachmentTypeRegistry,
});
},
});
// Return methods that should be available to other plugins
return {};
return {
attachmentFramework: {
registerExternalReference: (externalReferenceAttachmentType) => {
this.externalReferenceAttachmentTypeRegistry.register(externalReferenceAttachmentType);
},
},
};
}
public start(core: CoreStart, plugins: CasesPluginStart): CasesUiStart {
const config = this.initializerContext.config.get<CasesUiConfigType>();
KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion, config });
/**
* getCasesContextLazy returns a new component each time is being called. To avoid re-renders
* we get the component on start and provide the same component to all consumers.
*/
const getCasesContext = getCasesContextLazy({
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
});
return {
api: createClientAPI({ http: core.http }),
ui: {
getCases: getCasesLazy,
getCasesContext: getCasesContextLazy,
getRecentCases: getRecentCasesLazy,
getCases: (props) =>
getCasesLazy({
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
}),
getCasesContext,
getRecentCases: (props) =>
getRecentCasesLazy({
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
}),
// @deprecated Please use the hook getUseCasesAddToNewCaseFlyout
getCreateCaseFlyout: getCreateCaseFlyoutLazy,
getCreateCaseFlyout: (props) =>
getCreateCaseFlyoutLazy({
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
}),
// @deprecated Please use the hook getUseCasesAddToExistingCaseModal
getAllCasesSelectorModal: getAllCasesSelectorModalLazy,
getAllCasesSelectorModal: (props) =>
getAllCasesSelectorModalLazy({
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
}),
},
hooks: {
getUseCasesAddToNewCaseFlyout: useCasesAddToNewCaseFlyout,

View file

@ -6,7 +6,7 @@
*/
import { CoreStart, IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import { ReactElement, ReactNode } from 'react';
import React, { ReactElement } from 'react';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { EmbeddableStart } from '@kbn/embeddable-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
@ -37,6 +37,8 @@ import { GetCreateCaseFlyoutProps } from './client/ui/get_create_case_flyout';
import { GetRecentCasesProps } from './client/ui/get_recent_cases';
import { Cases, CasesStatus, CasesMetrics } from '../common/ui';
import { groupAlertsByRule } from './client/helpers/group_alerts_by_rule';
import { AttachmentFramework } from './client/attachment_framework/types';
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
export interface CasesPluginSetup {
security: SecurityPluginSetup;
@ -71,6 +73,11 @@ export interface RenderAppProps {
pluginsStart: CasesPluginStart;
storage: Storage;
kibanaVersion: string;
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
}
export interface CasesUiSetup {
attachmentFramework: AttachmentFramework;
}
export interface CasesUiStart {
@ -89,9 +96,8 @@ export interface CasesUiStart {
* @return {ReactElement<GetCasesProps>}
*/
getCases: (props: GetCasesProps) => ReactElement<GetCasesProps>;
getCasesContext: () => (
props: GetCasesContextProps & { children: ReactNode }
) => ReactElement<GetCasesContextProps>;
getCasesContext: () => React.FC<GetCasesContextProps>;
/**
* Modal to select a case in a list of all owner cases
* @param props GetAllCasesSelectorModalProps

View file

@ -6,10 +6,11 @@
*/
import pMap from 'p-map';
import { CasePostRequest } from '@kbn/cases-plugin/common/api';
import { CasePostRequest, CaseResponse } from '@kbn/cases-plugin/common/api';
import {
createCase as createCaseAPI,
deleteAllCaseItems,
createComment,
} from '../../../cases_api_integration/common/lib/utils';
import { FtrProviderContext } from '../../ftr_provider_context';
import { generateRandomCaseWithoutConnector } from './helpers';
@ -19,12 +20,13 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) {
const es = getService('es');
return {
async createCase(overwrites: Partial<CasePostRequest> = {}) {
async createCase(overwrites: Partial<CasePostRequest> = {}): Promise<CaseResponse> {
const caseData = {
...generateRandomCaseWithoutConnector(),
...overwrites,
} as CasePostRequest;
await createCaseAPI(kbnSupertest, caseData);
const res = await createCaseAPI(kbnSupertest, caseData);
return res;
},
async createNthRandomCases(amount: number = 3) {
@ -44,5 +46,15 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) {
async deleteAllCases() {
deleteAllCaseItems(es);
},
async createAttachment({
caseId,
params,
}: {
caseId: Parameters<typeof createComment>[0]['caseId'];
params: Parameters<typeof createComment>[0]['params'];
}): Promise<CaseResponse> {
return createComment({ supertest: kbnSupertest, params, caseId });
},
};
}

View 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 {
ExternalReferenceStorageType,
CommentType,
CaseResponse,
} from '@kbn/cases-plugin/common/api';
import { FtrProviderContext } from '../../ftr_provider_context';
export default ({ getPageObject, getService }: FtrProviderContext) => {
const header = getPageObject('header');
const testSubjects = getService('testSubjects');
const cases = getService('cases');
/**
* Attachment types are being registered in
* x-pack/test/functional_with_es_ssl/fixtures/plugins/cases/public/plugin.ts
*/
describe('Attachment framework', () => {
describe('External reference attachments', () => {
let caseWithAttachment: CaseResponse;
before(async () => {
const caseData = await cases.api.createCase({ title: 'External references' });
caseWithAttachment = await cases.api.createAttachment({
caseId: caseData.id,
params: {
type: CommentType.externalReference,
externalReferenceId: 'my-id',
externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc },
externalReferenceAttachmentTypeId: '.test',
externalReferenceMetadata: null,
owner: 'cases',
},
});
await cases.navigation.navigateToApp();
await cases.casesTable.waitForCasesToBeListed();
await cases.casesTable.goToFirstListedCase();
await header.waitUntilLoadingHasFinished();
});
after(async () => {
await cases.api.deleteAllCases();
});
it('renders an external reference attachment type correctly', async () => {
const attachmentId = caseWithAttachment?.comments?.[0].id;
await testSubjects.existOrFail('comment-external-reference-.test');
await testSubjects.existOrFail(`copy-link-${attachmentId}`);
await testSubjects.existOrFail('test-attachment-action');
await testSubjects.existOrFail('test-attachment-content');
});
});
});
};

View file

@ -13,5 +13,6 @@ export default ({ loadTestFile }: FtrProviderContext) => {
loadTestFile(require.resolve('./view_case'));
loadTestFile(require.resolve('./list_view'));
loadTestFile(require.resolve('./configure'));
loadTestFile(require.resolve('./attachment_framework'));
});
};

View file

@ -73,6 +73,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
`--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`,
`--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`,
`--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`,
`--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'cases')}`,
`--xpack.trigger_actions_ui.enableExperimental=${JSON.stringify([
'internalAlertsTable',
'internalShareableComponentsSandbox',

View file

@ -0,0 +1,9 @@
{
"id": "casesFixture",
"owner": { "name": "Response Ops", "githubTeam": "response-ops" },
"version": "1.0.0",
"kibanaVersion": "kibana",
"requiredPlugins": ["cases"],
"server": true,
"ui": true
}

View file

@ -0,0 +1,12 @@
{
"name": "cases-fixture",
"version": "0.0.0",
"kibana": {
"version": "kibana"
},
"main": "target/test/functional_with_es_ssl/fixtures/plugins/cases",
"scripts": {
"kbn": "node ../../../../../../scripts/kbn.js",
"build": "rm -rf './target' && ../../../../../../node_modules/.bin/tsc"
}
}

View file

@ -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, { lazy } from 'react';
import { EuiButtonIcon } from '@elastic/eui';
import { ExternalReferenceAttachmentType } from '@kbn/cases-plugin/public/client/attachment_framework/types';
const AttachmentContentLazy = lazy(() => import('./external_references_content'));
const AttachmentActions: React.FC = () => {
return (
<EuiButtonIcon
data-test-subj="test-attachment-action"
onClick={() => {}}
iconType="arrowRight"
aria-label="See attachment"
/>
);
};
export const getExternalReferenceAttachmentRegular = (): ExternalReferenceAttachmentType => ({
id: '.test',
icon: 'casesApp',
displayName: 'Test',
getAttachmentViewObject: () => ({
type: 'regular',
event: 'added a chart',
timelineIcon: 'casesApp',
actions: <AttachmentActions />,
children: AttachmentContentLazy,
}),
});

View file

@ -0,0 +1,53 @@
/*
* 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 { Chart, Settings, BarSeries, LineSeries, Axis, DataGenerator } from '@elastic/charts';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
const dg = new DataGenerator();
const data1 = dg.generateGroupedSeries(20, 1);
const data2 = dg.generateGroupedSeries(20, 5);
const AttachmentContent: React.FC = () => {
return (
<EuiFlexGroup data-test-subj="test-attachment-content">
<EuiFlexItem>
<Chart size={{ height: 200 }}>
<Settings showLegend={false} />
<BarSeries
id="status"
name="Status"
data={data2}
xAccessor={'x'}
yAccessors={['y']}
splitSeriesAccessors={['g']}
stackAccessors={['g']}
/>
<LineSeries
id="control"
name="Control"
data={data1}
xAccessor={'x'}
yAccessors={['y']}
color={['black']}
/>
<Axis id="bottom-axis" position="bottom" showGridLines />
<Axis
id="left-axis"
position="left"
showGridLines
tickFormat={(d) => Number(d).toFixed(2)}
/>
</Chart>
</EuiFlexItem>
</EuiFlexGroup>
);
};
// eslint-disable-next-line import/no-default-export
export { AttachmentContent as default };

View file

@ -0,0 +1,10 @@
/*
* 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 { CasesFixturePlugin } from './plugin';
export const plugin = () => new CasesFixturePlugin();

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Plugin, CoreSetup } from '@kbn/core/public';
import { CasesUiSetup } from '@kbn/cases-plugin/public/types';
import { getExternalReferenceAttachmentRegular } from './attachments/external_reference';
export type Setup = void;
export type Start = void;
export interface CasesExamplePublicSetupDeps {
cases: CasesUiSetup;
}
export class CasesFixturePlugin implements Plugin<Setup, Start, CasesExamplePublicSetupDeps> {
public setup(core: CoreSetup, { cases }: CasesExamplePublicSetupDeps) {
cases.attachmentFramework.registerExternalReference(getExternalReferenceAttachmentRegular());
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,11 @@
/*
* 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 { PluginInitializer } from '@kbn/core/server';
import { CasesFixturePlugin } from './plugin';
export const plugin: PluginInitializer<void, void> = () => new CasesFixturePlugin();

View file

@ -0,0 +1,14 @@
/*
* 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 { Plugin, CoreSetup } from '@kbn/core/server';
export class CasesFixturePlugin implements Plugin<void, void> {
public setup(core: CoreSetup) {}
public start() {}
public stop() {}
}