[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:
Antonio 2023-04-18 16:02:11 +02:00 committed by GitHub
parent b81a2705df
commit 0a38f85002
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 3442 additions and 117 deletions

View file

@ -7,7 +7,7 @@ pageLoadAssetSize:
banners: 17946
bfetch: 22837
canvas: 1066647
cases: 144442
cases: 170000
charts: 55000
cloud: 21076
cloudChat: 19894

View file

@ -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
*/

View 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>;

View file

@ -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,

View file

@ -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',
}

View file

@ -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>

View file

@ -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>;

View file

@ -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,
}}

View file

@ -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,

View file

@ -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>

View file

@ -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,

View file

@ -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,
}}

View file

@ -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,
};
};

View file

@ -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',
});
});
});
});

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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();
});
});

View file

@ -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}

View file

@ -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,
});
});
});
});

View file

@ -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(() => {

View file

@ -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',
})
);
});
});

View file

@ -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';

View file

@ -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',
{

View file

@ -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;
};

View 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();
});
});

View 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);

View file

@ -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();
});
});
});

View file

@ -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);

View file

@ -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,
});
});
});
});

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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';

View 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,
})
);
});
});
});

View 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,
});

View file

@ -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 },
})
);
});
});

View 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';

View file

@ -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');
});
});

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 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';

View 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?',
});

View 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 };

View file

@ -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();
});
});

View file

@ -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 };
};

View file

@ -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",
},
]
`);
});
});

View file

@ -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} />
),
},
],
},
];
};

View 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();
});
});

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 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
);
};

View file

@ -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',

View file

@ -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>
))}

View file

@ -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,
},
],
});

View file

@ -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>
),

View file

@ -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,
},

View file

@ -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,

View file

@ -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;
}

View file

@ -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} />

View file

@ -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 (
<>

View file

@ -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,

View file

@ -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);

View file

@ -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';

View file

@ -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

View file

@ -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,
};

View file

@ -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,

View file

@ -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',
});

View file

@ -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);
});
});

View file

@ -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>;

View file

@ -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();
});
});

View file

@ -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 });
},
}
);
};

View file

@ -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);
});
});
});

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 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 });
},
}
);
};

View file

@ -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
*/

View 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());
};

View file

@ -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: {

View file

@ -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'>;

View file

@ -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',
};

View file

@ -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/**/*",

View file

@ -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 = {

View file

@ -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,
}),

View file

@ -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),
}),