diff --git a/dev_docs/tutorials/versioning_http_apis.mdx b/dev_docs/tutorials/versioning_http_apis.mdx index c8a3625fb4977..81bfed4f4dc4e 100644 --- a/dev_docs/tutorials/versioning_http_apis.mdx +++ b/dev_docs/tutorials/versioning_http_apis.mdx @@ -41,7 +41,7 @@ router.get( ); ``` -#### Why is this problemetic for versioning? +#### Why is this problematic for versioning? Whenever we perform a data migration the body of this endpoint will change for all clients. This prevents us from being able to maintain past interfaces and gracefully introduce new ones. @@ -119,7 +119,7 @@ router.post( } ); ``` -#### Why is this problemetic for versioning? +#### Why is this problematic for versioning? This HTTP API currently accepts all numbers and strings as input which allows for unexpected inputs like negative numbers or non-URL friendly characters. This may break future migrations or integrations that assume your data will always be within certain parameters. @@ -141,7 +141,7 @@ This HTTP API currently accepts all numbers and strings as input which allows fo Adding this validation we negate the risk of unexpected values. It is not necessary to use `@kbn/config-schema`, as long as your validation mechanism provides finer grained controls than "number" or "string". -In summary: think about the acceptable paramaters for every input your HTTP API expects. +In summary: think about the acceptable parameters for every input your HTTP API expects. ### 3. Keep interfaces as "narrow" as possible @@ -170,7 +170,7 @@ router.get( The above code follows guidelines from steps 1 and 2, but it allows clients to specify ANY string by which to sort. This is a far "wider" API than we need for this endpoint. -#### Why is this problemetic for versioning? +#### Why is this problematic for versioning? Without telemetry it is impossible to know what values clients might be passing through — and what type of sort behaviour they are expecting. @@ -207,9 +207,112 @@ router.get( The changes are: -1. New input validation accepts a known set of values. This makes our HTTP API far _narrower_ and specific to our use case. It does not matter that our `sortSchema` has the same values as our persistence schema, what matters is that we created a **translation layer** between our HTTP API and our internal schema. This faclitates easily versioning this endpoint. +1. New input validation accepts a known set of values. This makes our HTTP API far _narrower_ and specific to our use case. It does not matter that our `sortSchema` has the same values as our persistence schema, what matters is that we created a **translation layer** between our HTTP API and our internal schema. This facilitates easily versioning this endpoint. 2. **Bonus point**: we use the `escapeKuery` utility to defend against KQL injection attacks. -### 4. Use the versioned API spec +### 4. Adhere to the HTTP versioning specification + +#### Choosing the right version + +##### Public endpoints +Public endpoints include any endpoint that is intended for users to directly integrate with via HTTP. + +Choose a date string in the format `YYYY-MM-DD`. This date should be the date that a (group) of APIs was made available. + +##### Internal endpoints +Internal endpoints are all non-public endpoints (see definition above). + +If you need to maintain backwards-compatibility for an internal endpoint use a single, larger-than-zero number. Ex. `1`. + + +#### Use the versioned router + +Core exposes a versioned router that ensures your endpoint's behaviour and formatting all conforms to the versioning specification. + +```typescript + router.versioned. + .post({ + access: 'public', // This endpoint is intended for a public audience + path: '/api/my-app/foo/{id?}', + options: { timeout: { payload: 60000 } }, + }) + .addVersion( + { + version: '2023-01-01', // The public version of this API + validate: { + request: { + query: schema.object({ + name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + }), + params: schema.object({ + id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + }), + body: schema.object({ foo: schema.string() }), + }, + response: { + 200: { // In development environments, this validation will run against 200 responses + body: schema.object({ foo: schema.string() }), + }, + }, + }, + }, + async (ctx, req, res) => { + await ctx.fooService.create(req.body.foo, req.params.id, req.query.name); + return res.ok({ body: { foo: req.body.foo } }); + } + ) + // BREAKING CHANGE: { foo: string } => { fooString: string } in response body + .addVersion( + { + version: '2023-02-01', + validate: { + request: { + query: schema.object({ + name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + }), + params: schema.object({ + id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + }), + body: schema.object({ fooString: schema.string() }), + }, + response: { + 200: { + body: schema.object({ fooName: schema.string() }), + }, + }, + }, + }, + async (ctx, req, res) => { + await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); + return res.ok({ body: { fooName: req.body.fooString } }); + } + ) + // BREAKING CHANGES: Enforce min/max length on fooString + .addVersion( + { + version: '2023-03-01', + validate: { + request: { + query: schema.object({ + name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + }), + params: schema.object({ + id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + }), + body: schema.object({ fooString: schema.string({ minLength: 0, maxLength: 1000 }) }), + }, + response: { + 200: { + body: schema.object({ fooName: schema.string() }), + }, + }, + }, + }, + async (ctx, req, res) => { + await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); + return res.ok({ body: { fooName: req.body.fooString } }); + } +``` -_Under construction, check back here soon!_ \ No newline at end of file +#### Additional reading +For a more details on the versioning specification see [this document](https://docs.google.com/document/d/1YpF6hXIHZaHvwNaQAxWFzexUF1nbqACTtH2IfDu0ldA/edit?usp=sharing). \ No newline at end of file diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 74d7df9394153..93d5507c53a1e 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -7,7 +7,7 @@ pageLoadAssetSize: banners: 17946 bfetch: 22837 canvas: 1066647 - cases: 144442 + cases: 170000 charts: 55000 cloud: 21076 cloudChat: 19894 diff --git a/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx b/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx index 498a4a93b5fe4..45e74312e1e55 100644 --- a/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx +++ b/packages/shared-ux/file/file_upload/impl/src/file_upload.tsx @@ -19,7 +19,7 @@ import { context } from './context'; /** * An object representing an uploaded file */ -interface UploadedFile { +export interface UploadedFile { /** * The ID that was generated for the uploaded file */ diff --git a/x-pack/packages/ml/url_state/src/url_state.test.tsx b/x-pack/packages/ml/url_state/src/url_state.test.tsx index 734c730dd91ba..033ecd77fadf4 100644 --- a/x-pack/packages/ml/url_state/src/url_state.test.tsx +++ b/x-pack/packages/ml/url_state/src/url_state.test.tsx @@ -5,29 +5,18 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { useEffect, type FC } from 'react'; import { render, act } from '@testing-library/react'; -import { parseUrlState, useUrlState, UrlStateProvider } from './url_state'; +import { MemoryRouter } from 'react-router-dom'; -const mockHistoryPush = jest.fn(); +import { parseUrlState, useUrlState, UrlStateProvider } from './url_state'; -jest.mock('react-router-dom', () => ({ - useHistory: () => ({ - push: mockHistoryPush, - }), - useLocation: () => ({ - search: - "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d", - }), -})); +const mockHistoryInitialState = + "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d"; describe('getUrlState', () => { test('properly decode url with _g and _a', () => { - expect( - parseUrlState( - "?_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFieldName:action),query:(query_string:(analyze_wildcard:!t,query:'*')))&_g=(ml:(jobIds:!(dec-2)),refreshInterval:(display:Off,pause:!t,value:0),time:(from:'2019-01-01T00:03:40.000Z',mode:absolute,to:'2019-08-30T11:55:07.000Z'))&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d" - ) - ).toEqual({ + expect(parseUrlState(mockHistoryInitialState)).toEqual({ _a: { mlExplorerFilter: {}, mlExplorerSwimlane: { @@ -46,7 +35,7 @@ describe('getUrlState', () => { }, refreshInterval: { display: 'Off', - pause: true, + pause: false, value: 0, }, time: { @@ -61,29 +50,96 @@ describe('getUrlState', () => { }); describe('useUrlState', () => { - beforeEach(() => { - mockHistoryPush.mockClear(); + it('pushes a properly encoded search string to history', () => { + const TestComponent: FC = () => { + const [appState, setAppState] = useUrlState('_a'); + + useEffect(() => { + setAppState(parseUrlState(mockHistoryInitialState)._a); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +
{JSON.stringify(appState?.query)}
+ + ); + }; + + const { getByText, getByTestId } = render( + + + + + + ); + + expect(getByTestId('appState').innerHTML).toBe( + '{"query_string":{"analyze_wildcard":true,"query":"*"}}' + ); + + act(() => { + getByText('ButtonText').click(); + }); + + expect(getByTestId('appState').innerHTML).toBe('"my-query"'); }); - test('pushes a properly encoded search string to history', () => { + it('updates both _g and _a state successfully', () => { const TestComponent: FC = () => { - const [, setUrlState] = useUrlState('_a'); - return ; + const [globalState, setGlobalState] = useUrlState('_g'); + const [appState, setAppState] = useUrlState('_a'); + + useEffect(() => { + setGlobalState({ time: 'initial time' }); + setAppState({ query: 'initial query' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + +
{globalState?.time}
+
{appState?.query}
+ + ); }; - const { getByText } = render( - - - + const { getByText, getByTestId } = render( + + + + + ); + expect(getByTestId('globalState').innerHTML).toBe('initial time'); + expect(getByTestId('appState').innerHTML).toBe('initial query'); + act(() => { - getByText('ButtonText').click(); + getByText('GlobalStateButton1').click(); }); - expect(mockHistoryPush).toHaveBeenCalledWith({ - search: - '_a=%28mlExplorerFilter%3A%28%29%2CmlExplorerSwimlane%3A%28viewByFieldName%3Aaction%29%2Cquery%3A%28%29%29&_g=%28ml%3A%28jobIds%3A%21%28dec-2%29%29%2CrefreshInterval%3A%28display%3AOff%2Cpause%3A%21f%2Cvalue%3A0%29%2Ctime%3A%28from%3A%272019-01-01T00%3A03%3A40.000Z%27%2Cmode%3Aabsolute%2Cto%3A%272019-08-30T11%3A55%3A07.000Z%27%29%29&savedSearchId=571aaf70-4c88-11e8-b3d7-01146121b73d', + expect(getByTestId('globalState').innerHTML).toBe('now-15m'); + expect(getByTestId('appState').innerHTML).toBe('initial query'); + + act(() => { + getByText('AppStateButton').click(); + }); + + expect(getByTestId('globalState').innerHTML).toBe('now-15m'); + expect(getByTestId('appState').innerHTML).toBe('the updated query'); + + act(() => { + getByText('GlobalStateButton2').click(); }); + + expect(getByTestId('globalState').innerHTML).toBe('now-5y'); + expect(getByTestId('appState').innerHTML).toBe('the updated query'); }); }); diff --git a/x-pack/packages/ml/url_state/src/url_state.tsx b/x-pack/packages/ml/url_state/src/url_state.tsx index d643a22bde6e4..bd62e1f61029a 100644 --- a/x-pack/packages/ml/url_state/src/url_state.tsx +++ b/x-pack/packages/ml/url_state/src/url_state.tsx @@ -94,6 +94,12 @@ export const UrlStateProvider: FC = ({ children }) => { const history = useHistory(); const { search: searchString } = useLocation(); + const searchStringRef = useRef(searchString); + + useEffect(() => { + searchStringRef.current = searchString; + }, [searchString]); + const setUrlState: SetUrlState = useCallback( ( accessor: Accessor, @@ -101,7 +107,8 @@ export const UrlStateProvider: FC = ({ children }) => { value?: any, replaceState?: boolean ) => { - const prevSearchString = searchString; + const prevSearchString = searchStringRef.current; + const urlState = parseUrlState(prevSearchString); const parsedQueryString = parse(prevSearchString, { sort: false }); @@ -142,6 +149,10 @@ export const UrlStateProvider: FC = ({ children }) => { if (oldLocationSearchString !== newLocationSearchString) { const newSearchString = stringify(parsedQueryString, { sort: false }); + // Another `setUrlState` call could happen before the updated + // `searchString` gets propagated via `useLocation` therefore + // we update the ref right away too. + searchStringRef.current = newSearchString; if (replaceState) { history.replace({ search: newSearchString }); } else { @@ -154,7 +165,7 @@ export const UrlStateProvider: FC = ({ children }) => { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [searchString] + [] ); return {children}; diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 3546cead3edab..82db675a94c4f 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -45,9 +45,6 @@ export const useData = ( } = useAiopsAppContext(); const [lastRefresh, setLastRefresh] = useState(0); - const [fieldStatsRequest, setFieldStatsRequest] = useState< - DocumentStatsSearchStrategyParams | undefined - >(); /** Prepare required params to pass to search strategy **/ const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => { @@ -91,12 +88,30 @@ export const useData = ( ]); const _timeBuckets = useTimeBuckets(); - const timefilter = useTimefilter({ timeRangeSelector: selectedDataView?.timeFieldName !== undefined, autoRefreshSelector: true, }); + const fieldStatsRequest: DocumentStatsSearchStrategyParams | undefined = useMemo(() => { + const timefilterActiveBounds = timefilter.getActiveBounds(); + if (timefilterActiveBounds !== undefined) { + _timeBuckets.setInterval('auto'); + _timeBuckets.setBounds(timefilterActiveBounds); + _timeBuckets.setBarTarget(barTarget); + return { + earliest: timefilterActiveBounds.min?.valueOf(), + latest: timefilterActiveBounds.max?.valueOf(), + intervalMs: _timeBuckets.getInterval()?.asMilliseconds(), + index: selectedDataView.getIndexPattern(), + searchQuery, + timeFieldName: selectedDataView.timeFieldName, + runtimeFieldMap: selectedDataView.getRuntimeMappings(), + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastRefresh, searchQuery]); + const overallStatsRequest = useMemo(() => { return fieldStatsRequest ? { @@ -125,25 +140,6 @@ export const useData = ( lastRefresh ); - function updateFieldStatsRequest() { - const timefilterActiveBounds = timefilter.getActiveBounds(); - if (timefilterActiveBounds !== undefined) { - _timeBuckets.setInterval('auto'); - _timeBuckets.setBounds(timefilterActiveBounds); - _timeBuckets.setBarTarget(barTarget); - setFieldStatsRequest({ - earliest: timefilterActiveBounds.min?.valueOf(), - latest: timefilterActiveBounds.max?.valueOf(), - intervalMs: _timeBuckets.getInterval()?.asMilliseconds(), - index: selectedDataView.getIndexPattern(), - searchQuery, - timeFieldName: selectedDataView.timeFieldName, - runtimeFieldMap: selectedDataView.getRuntimeMappings(), - }); - setLastRefresh(Date.now()); - } - } - useEffect(() => { const timefilterUpdateSubscription = merge( timefilter.getAutoRefreshFetch$(), @@ -156,13 +152,13 @@ export const useData = ( refreshInterval: timefilter.getRefreshInterval(), }); } - updateFieldStatsRequest(); + setLastRefresh(Date.now()); }); // This listens just for an initial update of the timefilter to be switched on. const timefilterEnabledSubscription = timefilter.getEnabledUpdated$().subscribe(() => { if (fieldStatsRequest === undefined) { - updateFieldStatsRequest(); + setLastRefresh(Date.now()); } }); @@ -173,12 +169,6 @@ export const useData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Ensure request is updated when search changes - useEffect(() => { - updateFieldStatsRequest(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchString, JSON.stringify(searchQuery)]); - return { documentStats, timefilter, diff --git a/x-pack/plugins/cases/common/api/cases/comment/files.ts b/x-pack/plugins/cases/common/api/cases/comment/files.ts index 66555b1a584d9..af42a7a779e55 100644 --- a/x-pack/plugins/cases/common/api/cases/comment/files.ts +++ b/x-pack/plugins/cases/common/api/cases/comment/files.ts @@ -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; diff --git a/x-pack/plugins/cases/common/constants/mime_types.ts b/x-pack/plugins/cases/common/constants/mime_types.ts index 9f1f455513dab..c35e5ef674c81 100644 --- a/x-pack/plugins/cases/common/constants/mime_types.ts +++ b/x-pack/plugins/cases/common/constants/mime_types.ts @@ -8,7 +8,7 @@ /** * These were retrieved from https://www.iana.org/assignments/media-types/media-types.xhtml#image */ -const imageMimeTypes = [ +export const imageMimeTypes = [ 'image/aces', 'image/apng', 'image/avci', @@ -87,9 +87,9 @@ const imageMimeTypes = [ 'image/wmf', ]; -const textMimeTypes = ['text/plain', 'text/csv', 'text/json', 'application/json']; +export const textMimeTypes = ['text/plain', 'text/csv', 'text/json', 'application/json']; -const compressionMimeTypes = [ +export const compressionMimeTypes = [ 'application/zip', 'application/gzip', 'application/x-bzip', @@ -98,7 +98,7 @@ const compressionMimeTypes = [ 'application/x-tar', ]; -const pdfMimeTypes = ['application/pdf']; +export const pdfMimeTypes = ['application/pdf']; export const ALLOWED_MIME_TYPES = [ ...imageMimeTypes, diff --git a/x-pack/plugins/cases/common/types.ts b/x-pack/plugins/cases/common/types.ts index 3ff14b0905110..32d6b34b11c16 100644 --- a/x-pack/plugins/cases/common/types.ts +++ b/x-pack/plugins/cases/common/types.ts @@ -24,4 +24,5 @@ export type SnakeToCamelCase = T extends Record export enum CASE_VIEW_PAGE_TABS { ALERTS = 'alerts', ACTIVITY = 'activity', + FILES = 'files', } diff --git a/x-pack/plugins/cases/public/application.tsx b/x-pack/plugins/cases/public/application.tsx index bac423a9f8292..742f254472160 100644 --- a/x-pack/plugins/cases/public/application.tsx +++ b/x-pack/plugins/cases/public/application.tsx @@ -9,19 +9,21 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Router } from 'react-router-dom'; -import { I18nProvider } from '@kbn/i18n-react'; import { EuiErrorBoundary } from '@elastic/eui'; - +import { I18nProvider } from '@kbn/i18n-react'; +import { EuiThemeProvider as StyledComponentsThemeProvider } from '@kbn/kibana-react-plugin/common'; import { KibanaContextProvider, KibanaThemeProvider, useUiSetting$, } from '@kbn/kibana-react-plugin/public'; -import { EuiThemeProvider as StyledComponentsThemeProvider } from '@kbn/kibana-react-plugin/common'; -import type { RenderAppProps } from './types'; -import { CasesApp } from './components/app'; + +import type { ScopedFilesClient } from '@kbn/files-plugin/public'; import type { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry'; import type { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry'; +import type { RenderAppProps } from './types'; + +import { CasesApp } from './components/app'; export const renderApp = (deps: RenderAppProps) => { const { mountParams } = deps; @@ -37,10 +39,15 @@ export const renderApp = (deps: RenderAppProps) => { interface CasesAppWithContextProps { externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; + getFilesClient: (scope: string) => ScopedFilesClient; } const CasesAppWithContext: React.FC = React.memo( - ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry }) => { + ({ + externalReferenceAttachmentTypeRegistry, + persistableStateAttachmentTypeRegistry, + getFilesClient, + }) => { const [darkMode] = useUiSetting$('theme:darkMode'); return ( @@ -48,6 +55,7 @@ const CasesAppWithContext: React.FC = React.memo( ); @@ -78,6 +86,7 @@ export const App: React.FC<{ deps: RenderAppProps }> = ({ deps }) => { deps.externalReferenceAttachmentTypeRegistry } persistableStateAttachmentTypeRegistry={deps.persistableStateAttachmentTypeRegistry} + getFilesClient={pluginsStart.files.filesClientFactory.asScoped} /> diff --git a/x-pack/plugins/cases/public/client/attachment_framework/types.ts b/x-pack/plugins/cases/public/client/attachment_framework/types.ts index 414b8a0086654..95a453b9d0a12 100644 --- a/x-pack/plugins/cases/public/client/attachment_framework/types.ts +++ b/x-pack/plugins/cases/public/client/attachment_framework/types.ts @@ -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 { timelineAvatar?: EuiCommentProps['timelineAvatar']; getActions?: (props: Props) => AttachmentAction[]; event?: EuiCommentProps['event']; children?: React.LazyExoticComponent>; + hideDefaultActions?: boolean; } export interface CommonAttachmentViewProps { @@ -46,8 +65,9 @@ export interface AttachmentType { id: string; icon: IconType; displayName: string; - getAttachmentViewObject: () => AttachmentViewObject; + getAttachmentViewObject: (props: Props) => AttachmentViewObject; getAttachmentRemovalObject?: (props: Props) => Pick, 'event'>; + hideDefaultActions?: boolean; } export type ExternalReferenceAttachmentType = AttachmentType; diff --git a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx index b0807b0509135..fc85e84639baa 100644 --- a/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_all_cases_selector_modal.tsx @@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context'; type GetAllCasesSelectorModalPropsInternal = AllCasesSelectorModalProps & CasesContextProps; export type GetAllCasesSelectorModalProps = Omit< GetAllCasesSelectorModalPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const AllCasesSelectorModalLazy: React.FC = lazy( @@ -23,6 +25,7 @@ const AllCasesSelectorModalLazy: React.FC = lazy( export const getAllCasesSelectorModalLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, hiddenStatuses, @@ -33,6 +36,7 @@ export const getAllCasesSelectorModalLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, }} diff --git a/x-pack/plugins/cases/public/client/ui/get_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_cases.tsx index 45c9f30b984d2..36556523fc3a3 100644 --- a/x-pack/plugins/cases/public/client/ui/get_cases.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_cases.tsx @@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context'; type GetCasesPropsInternal = CasesProps & CasesContextProps; export type GetCasesProps = Omit< GetCasesPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const CasesRoutesLazy: React.FC = lazy(() => import('../../components/app/routes')); @@ -22,6 +24,7 @@ const CasesRoutesLazy: React.FC = lazy(() => import('../../component export const getCasesLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, basePath, @@ -39,6 +42,7 @@ export const getCasesLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, basePath, diff --git a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx index 77e6ca3c87e24..9db49ef9776ba 100644 --- a/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_cases_context.tsx @@ -13,7 +13,9 @@ import type { CasesContextProps } from '../../components/cases_context'; export type GetCasesContextPropsInternal = CasesContextProps; export type GetCasesContextProps = Omit< CasesContextProps, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const CasesProviderLazy: React.FC<{ value: GetCasesContextPropsInternal }> = lazy( @@ -28,6 +30,7 @@ const CasesProviderLazyWrapper = ({ features, children, releasePhase, + getFilesClient, }: GetCasesContextPropsInternal & { children: ReactNode }) => { return ( }> @@ -39,6 +42,7 @@ const CasesProviderLazyWrapper = ({ permissions, features, releasePhase, + getFilesClient, }} > {children} @@ -52,9 +56,12 @@ CasesProviderLazyWrapper.displayName = 'CasesProviderLazyWrapper'; export const getCasesContextLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, }: Pick< GetCasesContextPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >): (() => React.FC) => { const CasesProviderLazyWrapperWithRegistry: React.FC = ({ children, @@ -64,6 +71,7 @@ export const getCasesContextLazy = ({ {...props} externalReferenceAttachmentTypeRegistry={externalReferenceAttachmentTypeRegistry} persistableStateAttachmentTypeRegistry={persistableStateAttachmentTypeRegistry} + getFilesClient={getFilesClient} > {children} diff --git a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx index af932b53e1dde..e52a14033a614 100644 --- a/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_create_case_flyout.tsx @@ -14,7 +14,9 @@ import { CasesProvider } from '../../components/cases_context'; type GetCreateCaseFlyoutPropsInternal = CreateCaseFlyoutProps & CasesContextProps; export type GetCreateCaseFlyoutProps = Omit< GetCreateCaseFlyoutPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; export const CreateCaseFlyoutLazy: React.FC = lazy( @@ -23,6 +25,7 @@ export const CreateCaseFlyoutLazy: React.FC = lazy( export const getCreateCaseFlyoutLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, features, @@ -35,6 +38,7 @@ export const getCreateCaseFlyoutLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, features, diff --git a/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx index a047c106246da..7c41cc3842bf7 100644 --- a/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx +++ b/x-pack/plugins/cases/public/client/ui/get_recent_cases.tsx @@ -14,7 +14,9 @@ import type { RecentCasesProps } from '../../components/recent_cases'; type GetRecentCasesPropsInternal = RecentCasesProps & CasesContextProps; export type GetRecentCasesProps = Omit< GetRecentCasesPropsInternal, - 'externalReferenceAttachmentTypeRegistry' | 'persistableStateAttachmentTypeRegistry' + | 'externalReferenceAttachmentTypeRegistry' + | 'persistableStateAttachmentTypeRegistry' + | 'getFilesClient' >; const RecentCasesLazy: React.FC = lazy( @@ -23,6 +25,7 @@ const RecentCasesLazy: React.FC = lazy( export const getRecentCasesLazy = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, maxCasesToShow, @@ -31,6 +34,7 @@ export const getRecentCasesLazy = ({ value={{ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner, permissions, }} diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 2a5a75bf7a789..f0b2e71231bb1 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -9,22 +9,30 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; + +import type { RenderOptions, RenderResult } from '@testing-library/react'; +import type { ILicense } from '@kbn/licensing-plugin/public'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { ScopedFilesClient } from '@kbn/files-plugin/public'; + import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; -import { ThemeProvider } from 'styled-components'; +import { createMockFilesClient } from '@kbn/shared-ux-file-mocks'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { RenderOptions, RenderResult } from '@testing-library/react'; import { render as reactRender } from '@testing-library/react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import type { ILicense } from '@kbn/licensing-plugin/public'; -import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import { FilesContext } from '@kbn/shared-ux-file-context'; + +import type { 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 + >; + + 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 = ({ 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 = ({ }, }); - const services = createStartServicesMock({ license }); + const getFilesClient = mockGetFilesClient(); return ( @@ -82,9 +108,10 @@ const TestProvidersComponent: React.FC = ({ features, owner, permissions, + getFilesClient, }} > - {children} + {children} @@ -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 }) => ( @@ -161,6 +191,7 @@ export const createAppMockRenderer = ({ owner, permissions, releasePhase, + getFilesClient, }} > {children} @@ -188,6 +219,7 @@ export const createAppMockRenderer = ({ AppWrapper, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, }; }; diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index 6c33c86d29d51..d6597e31362e7 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -25,6 +25,7 @@ const useKibanaMock = useKibana as jest.Mocked; describe('Use cases toast hook', () => { const successMock = jest.fn(); const errorMock = jest.fn(); + const dangerMock = jest.fn(); const getUrlForApp = jest.fn().mockReturnValue(`/app/cases/${mockCase.id}`); const navigateToUrl = jest.fn(); @@ -54,6 +55,7 @@ describe('Use cases toast hook', () => { return { addSuccess: successMock, addError: errorMock, + addDanger: dangerMock, }; }); @@ -352,4 +354,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', + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index fd143345e2deb..26027905f8f0e 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -169,6 +169,9 @@ export const useCasesToast = () => { showSuccessToast: (title: string) => { toasts.addSuccess({ title, className: 'eui-textBreakWord' }); }, + showDangerToast: (title: string) => { + toasts.addDanger({ title, className: 'eui-textBreakWord' }); + }, showInfoToast: (title: string, text?: string) => { toasts.addInfo({ title, diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index 42ef9b658fea7..f53e7edf9356a 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -6,12 +6,15 @@ */ import React from 'react'; -import { APP_OWNER } from '../../../common/constants'; + +import type { ScopedFilesClient } from '@kbn/files-plugin/public'; + import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry'; import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry'; + +import { APP_OWNER } from '../../../common/constants'; import { getCasesLazy } from '../../client/ui/get_cases'; import { useApplicationCapabilities } from '../../common/lib/kibana'; - import { Wrapper } from '../wrappers'; import type { CasesRoutesProps } from './types'; @@ -20,11 +23,13 @@ export type CasesProps = CasesRoutesProps; interface CasesAppProps { externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; + getFilesClient: (scope: string) => ScopedFilesClient; } const CasesAppComponent: React.FC = ({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, }) => { const userCapabilities = useApplicationCapabilities(); @@ -33,6 +38,7 @@ const CasesAppComponent: React.FC = ({ {getCasesLazy({ externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + getFilesClient, owner: [APP_OWNER], useFetchAlertData: () => [false, {}], permissions: userCapabilities.generalCases, diff --git a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx index 87cd1fc732a30..b80cd5c2dbe74 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx @@ -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 = ({ 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 = ({ 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 = ({ caseData, currentExternal ...(permissions.delete ? [ { + type: AttachmentActionType.BUTTON as const, iconType: 'trash', label: i18n.DELETE_CASE(), color: 'danger' as const, diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index bf348124e4616..f247945c7c700 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -493,8 +493,9 @@ describe('CaseViewPage', () => { it('renders tabs correctly', async () => { const result = appMockRenderer.render(); await act(async () => { - expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy(); expect(result.getByTestId('case-view-tab-title-activity')).toBeTruthy(); + expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy(); + expect(result.getByTestId('case-view-tab-title-files')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index a26793e501897..55245de4b22b2 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -18,6 +18,7 @@ import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; import { WhitePageWrapperNoBorder } from '../wrappers'; import { CaseViewActivity } from './components/case_view_activity'; import { CaseViewAlerts } from './components/case_view_alerts'; +import { CaseViewFiles } from './components/case_view_files'; import { CaseViewMetrics } from './metrics'; import type { CaseViewPageProps } from './types'; import { useRefreshCaseViewPage } from './use_on_refresh_case_view_page'; @@ -140,6 +141,7 @@ export const CaseViewPage = React.memo( {activeTabId === CASE_VIEW_PAGE_TABS.ALERTS && features.alerts.enabled && ( )} + {activeTabId === CASE_VIEW_PAGE_TABS.FILES && } {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx index a3da7d90267cf..bd532d95ba58b 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx @@ -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 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 type { UseGetCase } from '../../containers/use_get_case'; 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 = {}) => { 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(); + + 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(); + + 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(); + + 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(); + + 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(); @@ -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(); + + userEvent.click(await screen.findByTestId('case-view-tab-title-files')); + + await waitFor(() => { + expect(navigateToCaseViewMock).toHaveBeenCalledWith({ + detailName: caseData.id, + tabId: CASE_VIEW_PAGE_TABS.FILES, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx index 746311051f147..630248bf79d52 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx @@ -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 && ( + + {fileStatsData.total > 0 ? fileStatsData.total : 0} + + )} + +); + +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(({ 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(({ caseData, activeTab }, ] : []), + { + id: CASE_VIEW_PAGE_TABS.FILES, + name: ( + + ), + }, ], - [features.alerts.enabled, features.alerts.isExperimental] + [activeTab, features.alerts.enabled, features.alerts.isExperimental, fileStatsData, isLoading] ); const renderTabs = useCallback(() => { diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx new file mode 100644 index 0000000000000..dc5b937bd8781 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.test.tsx @@ -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(); + + expect((await screen.findAllByTestId('cases-files-add')).length).toBe(2); + expect(await screen.findByTestId('cases-files-search')).toBeInTheDocument(); + }); + + it('should render the files table', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument(); + }); + + it('clicking table pagination triggers calls to useGetCaseFiles', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('pagination-button-next')); + + await waitFor(() => + expect(useGetCaseFilesMock).toHaveBeenCalledWith({ + caseId: basicCase.id, + page: DEFAULT_CASE_FILES_FILTERING_OPTIONS.page + 1, + perPage: DEFAULT_CASE_FILES_FILTERING_OPTIONS.perPage, + }) + ); + }); + + it('changing perPage value triggers calls to useGetCaseFiles', async () => { + const targetPagination = 50; + + appMockRender.render(); + + 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(); + + 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', + }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx new file mode 100644 index 0000000000000..54693acfa2390 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_files.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { isEqual } from 'lodash/fp'; +import React, { useCallback, useMemo, useState } from 'react'; + +import type { Criteria } from '@elastic/eui'; +import type { FileJSON } from '@kbn/shared-ux-file-types'; + +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import type { Case } from '../../../../common/ui/types'; +import type { CaseFilesFilteringOptions } from '../../../containers/use_get_case_files'; + +import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; +import { useGetCaseFiles } from '../../../containers/use_get_case_files'; +import { FilesTable } from '../../files/files_table'; +import { CaseViewTabs } from '../case_view_tabs'; +import { FilesUtilityBar } from '../../files/files_utility_bar'; + +interface CaseViewFilesProps { + caseData: Case; +} + +export const DEFAULT_CASE_FILES_FILTERING_OPTIONS = { + page: 0, + perPage: 10, +}; + +export const CaseViewFiles = ({ caseData }: CaseViewFilesProps) => { + const [filteringOptions, setFilteringOptions] = useState( + DEFAULT_CASE_FILES_FILTERING_OPTIONS + ); + const { + data: caseFiles, + isLoading, + isPreviousData, + } = useGetCaseFiles({ + ...filteringOptions, + caseId: caseData.id, + }); + + const onTableChange = useCallback( + ({ page }: Criteria) => { + if (page && !isPreviousData) { + setFilteringOptions({ + ...filteringOptions, + page: page.index, + perPage: page.size, + }); + } + }, + [filteringOptions, isPreviousData] + ); + + const onSearchChange = useCallback( + (newSearch) => { + const trimSearch = newSearch.trim(); + if (!isEqual(trimSearch, filteringOptions.searchTerm)) { + setFilteringOptions({ + ...filteringOptions, + searchTerm: trimSearch, + }); + } + }, + [filteringOptions] + ); + + const pagination = useMemo( + () => ({ + pageIndex: filteringOptions.page, + pageSize: filteringOptions.perPage, + totalItemCount: caseFiles?.total ?? 0, + pageSizeOptions: [10, 25, 50], + showPerPageOptions: true, + }), + [filteringOptions.page, filteringOptions.perPage, caseFiles?.total] + ); + + return ( + + + + + + + + + + + + ); +}; + +CaseViewFiles.displayName = 'CaseViewFiles'; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index d71c56fc97fca..8fc80c1a0aba3 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -165,6 +165,10 @@ export const ALERTS_TAB = i18n.translate('xpack.cases.caseView.tabs.alerts', { defaultMessage: 'Alerts', }); +export const FILES_TAB = i18n.translate('xpack.cases.caseView.tabs.files', { + defaultMessage: 'Files', +}); + export const ALERTS_EMPTY_DESCRIPTION = i18n.translate( 'xpack.cases.caseView.tabs.alerts.emptyDescription', { diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index 4e31fffdd7701..dc7eac6381b4d 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -5,25 +5,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; 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(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 ( + + {contextChildren} + + ); + } else { + throw new Error( + 'Invalid owner provided to cases context. See https://github.com/elastic/kibana/blob/main/x-pack/plugins/cases/README.md#casescontext-setup' + ); + } + }, + [getFilesClient, owner] + ); + return isCasesContextValue(value) ? ( - - {children} + {applyFilesContext( + <> + + {children} + + )} ) : null; }; diff --git a/x-pack/plugins/cases/public/components/files/add_file.test.tsx b/x-pack/plugins/cases/public/components/files/add_file.test.tsx new file mode 100644 index 0000000000000..911f8a4df538d --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/add_file.test.tsx @@ -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>) => ( + <> + + + + + ) + ); + +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(); + + 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(); + + 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(); + + expect(screen.queryByTestId('cases-files-add')).not.toBeInTheDocument(); + }); + + it('clicking button renders modal', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + }); + + it('createAttachments called with right parameters', async () => { + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('testOnDone')); + + await waitFor(() => + expect(createAttachmentsMock).toBeCalledWith({ + 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(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('testOnError')); + + expect(errorMock).toHaveBeenCalledWith( + { name: 'upload error name', message: 'upload error message' }, + { + title: 'Failed to upload file', + } + ); + }); + + it('correct metadata is passed to FileUpload component', async () => { + const caseId = 'foobar'; + + appMockRender.render(); + + userEvent.click(await screen.findByTestId('cases-files-add')); + + expect(await screen.findByTestId('cases-files-add-modal')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('testMetadata')); + + await waitFor(() => + expect(validateMetadata).toHaveBeenCalledWith({ + 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(); + + 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(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/add_file.tsx b/x-pack/plugins/cases/public/components/files/add_file.tsx new file mode 100644 index 0000000000000..a3c9fba1188ea --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/add_file.tsx @@ -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 = ({ 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 ? ( + + + {i18n.ADD_FILE} + + {isModalVisible && ( + + + {i18n.ADD_FILE} + + + + + + )} + + ) : null; +}; +AddFileComponent.displayName = 'AddFile'; + +export const AddFile = React.memo(AddFileComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx b/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx new file mode 100644 index 0000000000000..38ed8a20eab40 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_delete_button.test.tsx @@ -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( + + ); + + expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); + + expect(useDeleteFileAttachmentMock).toBeCalledTimes(1); + }); + + it('clicking delete button opens the confirmation modal', async () => { + appMockRender.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('clicking delete button in the confirmation modal calls deleteFileAttachment with proper params', async () => { + appMockRender.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(); + + 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( + + ); + + 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(); + + expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument(); + + expect(useDeleteFileAttachmentMock).toBeCalledTimes(1); + }); + + it('clicking delete button opens the confirmation modal', async () => { + appMockRender.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('clicking delete button in the confirmation modal calls deleteFileAttachment with proper params', async () => { + appMockRender.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(); + + 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(); + + expect(screen.queryByTestId('cases-files-delete-button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_delete_button.tsx b/x-pack/plugins/cases/public/components/files/file_delete_button.tsx new file mode 100644 index 0000000000000..f344b942aa2c2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_delete_button.tsx @@ -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 = ({ 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 ? ( + + ) : ( + {i18n.DELETE_FILE} + )} + {showDeletionModal ? ( + + ) : null} + + ) : ( + <> + ); +}; +FileDeleteButtonComponent.displayName = 'FileDeleteButton'; + +export const FileDeleteButton = React.memo(FileDeleteButtonComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_download_button.test.tsx b/x-pack/plugins/cases/public/components/files/file_download_button.test.tsx new file mode 100644 index 0000000000000..0c729900a9ea6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_download_button.test.tsx @@ -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(); + + 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(); + + 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, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_download_button.tsx b/x-pack/plugins/cases/public/components/files/file_download_button.tsx new file mode 100644 index 0000000000000..856c7000ba9d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_download_button.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { 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 = ({ 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 ? ( + + ) : ( + {i18n.DOWNLOAD_FILE} + ); +}; +FileDownloadButtonComponent.displayName = 'FileDownloadButton'; + +export const FileDownloadButton = React.memo(FileDownloadButtonComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_name_link.test.tsx b/x-pack/plugins/cases/public/components/files/file_name_link.test.tsx new file mode 100644 index 0000000000000..39c62322dedeb --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_name_link.test.tsx @@ -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(); + + 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( + + ); + + const nameLink = await screen.findByTestId('cases-files-name-text'); + + expect(nameLink).toBeInTheDocument(); + + userEvent.click(nameLink); + + expect(defaultProps.showPreview).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_name_link.tsx b/x-pack/plugins/cases/public/components/files/file_name_link.tsx new file mode 100644 index 0000000000000..4c9aedc3ad85b --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_name_link.tsx @@ -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; + showPreview: () => void; +} + +const FileNameLinkComponent: React.FC = ({ file, showPreview }) => { + let fileName = file.name; + + if (typeof file.extension !== 'undefined') { + fileName += `.${file.extension}`; + } + + if (isImage(file)) { + return ( + + {fileName} + + ); + } else { + return ( + + {fileName} + + ); + } +}; +FileNameLinkComponent.displayName = 'FileNameLink'; + +export const FileNameLink = React.memo(FileNameLinkComponent); diff --git a/x-pack/plugins/cases/public/components/files/file_preview.test.tsx b/x-pack/plugins/cases/public/components/files/file_preview.test.tsx new file mode 100644 index 0000000000000..b02df3a82228f --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_preview.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +import { 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(); + + await waitFor(() => + expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({ + id: basicFileMock.id, + fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]), + }) + ); + + expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_preview.tsx b/x-pack/plugins/cases/public/components/files/file_preview.tsx new file mode 100644 index 0000000000000..1bb91c5b53ff7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_preview.tsx @@ -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; +} + +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 ( + + + + + + ); +}; + +FilePreview.displayName = 'FilePreview'; diff --git a/x-pack/plugins/cases/public/components/files/file_type.test.tsx b/x-pack/plugins/cases/public/components/files/file_type.test.tsx new file mode 100644 index 0000000000000..8d4fd4c0eabde --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_type.test.tsx @@ -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, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/file_type.tsx b/x-pack/plugins/cases/public/components/files/file_type.tsx new file mode 100644 index 0000000000000..271bf3008e70e --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/file_type.tsx @@ -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} + + {isPreviewVisible && } + + ); +}; + +FileAttachmentEvent.displayName = 'FileAttachmentEvent'; + +function getFileDownloadButton(fileId: string) { + return ; +} + +function getFileDeleteButton(caseId: string, fileId: string) { + return ; +} + +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: , + 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, +}); diff --git a/x-pack/plugins/cases/public/components/files/files_table.test.tsx b/x-pack/plugins/cases/public/components/files/files_table.test.tsx new file mode 100644 index 0000000000000..651f86e76b462 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_table.test.tsx @@ -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(); + + 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(); + + expect(await screen.findByTestId('cases-files-table-loading')).toBeInTheDocument(); + }); + + it('renders empty table', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-table-empty')).toBeInTheDocument(); + }); + + it('FileAdd in empty table is clickable', async () => { + appMockRender.render(); + + 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(); + + 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(); + + userEvent.click( + await within(await screen.findByTestId('cases-files-table-filename')).findByTitle( + 'No preview available' + ) + ); + + expect(await screen.queryByTestId('cases-files-image-preview')).not.toBeInTheDocument(); + }); + + it('image rows open file preview', async () => { + appMockRender.render(); + + userEvent.click( + await screen.findByRole('button', { + name: `${basicFileMock.name}.${basicFileMock.extension}`, + }) + ); + + expect(await screen.findByTestId('cases-files-image-preview')).toBeInTheDocument(); + }); + + it('different mimeTypes are displayed correctly', async () => { + const mockPagination = { pageIndex: 0, pageSize: 10, totalItemCount: 7 }; + appMockRender.render( + + ); + + expect((await screen.findAllByText('Unknown')).length).toBe(4); + expect(await screen.findByText('Compressed')).toBeInTheDocument(); + expect(await screen.findByText('Text')).toBeInTheDocument(); + expect(await screen.findByText('Image')).toBeInTheDocument(); + }); + + it('download button renders correctly', async () => { + appMockRender.render(); + + 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(); + + 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(); + + 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( + + ); + + userEvent.click(await screen.findByTestId('pagination-button-next')); + + await waitFor(() => + expect(onTableChange).toHaveBeenCalledWith({ + page: { index: mockPagination.pageIndex + 1, size: mockPagination.pageSize }, + }) + ); + }); + + it('go to previous page calls onTableChange with correct values', async () => { + const mockPagination = { pageIndex: 1, pageSize: 1, totalItemCount: 2 }; + + appMockRender.render( + + ); + + userEvent.click(await screen.findByTestId('pagination-button-previous')); + + await waitFor(() => + expect(onTableChange).toHaveBeenCalledWith({ + page: { index: mockPagination.pageIndex - 1, size: mockPagination.pageSize }, + }) + ); + }); + + it('changing perPage calls onTableChange with correct values', async () => { + appMockRender.render( + + ); + + 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 }, + }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/files_table.tsx b/x-pack/plugins/cases/public/components/files/files_table.tsx new file mode 100644 index 0000000000000..6433d90a91d44 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_table.tsx @@ -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 }) => ( + {i18n.NO_FILES}} + data-test-subj="cases-files-table-empty" + titleSize="xs" + actions={} + /> +); + +EmptyFilesTable.displayName = 'EmptyFilesTable'; + +interface FilesTableProps { + caseId: string; + isLoading: boolean; + items: FileJSON[]; + onChange: EuiBasicTableProps['onChange']; + pagination: Pagination; +} + +export const FilesTable = ({ caseId, items, pagination, onChange, isLoading }: FilesTableProps) => { + const { isPreviewVisible, showPreview, closePreview } = useFilePreview(); + + const [selectedFile, setSelectedFile] = useState(); + + const displayPreview = (file: FileJSON) => { + setSelectedFile(file); + showPreview(); + }; + + const columns = useFilesTableColumns({ caseId, showPreview: displayPreview }); + + return isLoading ? ( + <> + + + + ) : ( + <> + {pagination.totalItemCount > 0 && ( + <> + + + {i18n.SHOWING_FILES(items.length)} + + + )} + + } + /> + {isPreviewVisible && selectedFile !== undefined && ( + + )} + + ); +}; + +FilesTable.displayName = 'FilesTable'; diff --git a/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx b/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx new file mode 100644 index 0000000000000..bfac1998a857a --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_utility_bar.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { FilesUtilityBar } from './files_utility_bar'; + +const defaultProps = { + caseId: 'foobar', + onSearch: jest.fn(), +}; + +describe('FilesUtilityBar', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-files-search')).toBeInTheDocument(); + }); + + it('search text passed correctly to callback', async () => { + appMockRender.render(); + + await userEvent.type(screen.getByTestId('cases-files-search'), 'My search{enter}'); + expect(defaultProps.onSearch).toBeCalledWith('My search'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx b/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx new file mode 100644 index 0000000000000..71b1ef503fc63 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/files_utility_bar.tsx @@ -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 ( + + + + + + + ); +}; + +FilesUtilityBar.displayName = 'FilesUtilityBar'; diff --git a/x-pack/plugins/cases/public/components/files/translations.tsx b/x-pack/plugins/cases/public/components/files/translations.tsx new file mode 100644 index 0000000000000..4023c5b18cea8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/translations.tsx @@ -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?', +}); diff --git a/x-pack/plugins/cases/public/components/files/types.ts b/x-pack/plugins/cases/public/components/files/types.ts new file mode 100644 index 0000000000000..a211b5ac4053d --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/types.ts @@ -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 & { id: string }; diff --git a/x-pack/plugins/cases/public/components/files/use_file_preview.test.tsx b/x-pack/plugins/cases/public/components/files/use_file_preview.test.tsx new file mode 100644 index 0000000000000..49e18fb818cd9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_file_preview.test.tsx @@ -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(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/use_file_preview.tsx b/x-pack/plugins/cases/public/components/files/use_file_preview.tsx new file mode 100644 index 0000000000000..c802aa38fc688 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_file_preview.tsx @@ -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 }; +}; diff --git a/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx b/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx new file mode 100644 index 0000000000000..77070da0dbc57 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_files_table_columns.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx b/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx new file mode 100644 index 0000000000000..80568189afb58 --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/use_files_table_columns.tsx @@ -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> => { + return [ + { + name: i18n.NAME, + 'data-test-subj': 'cases-files-table-filename', + render: (file: FileJSON) => ( + showPreview(file)} /> + ), + width: '60%', + }, + { + name: i18n.TYPE, + 'data-test-subj': 'cases-files-table-filetype', + render: (attachment: FileJSON) => { + return {parseMimeType(attachment.mimeType)}; + }, + }, + { + name: i18n.DATE_ADDED, + field: 'created', + 'data-test-subj': 'cases-files-table-date-added', + dataType: 'date', + }, + { + name: i18n.ACTIONS, + width: '120px', + actions: [ + { + name: 'Download', + isPrimary: true, + description: i18n.DOWNLOAD_FILE, + render: (file: FileJSON) => , + }, + { + name: 'Delete', + isPrimary: true, + description: i18n.DELETE_FILE, + render: (file: FileJSON) => ( + + ), + }, + ], + }, + ]; +}; diff --git a/x-pack/plugins/cases/public/components/files/utils.test.tsx b/x-pack/plugins/cases/public/components/files/utils.test.tsx new file mode 100644 index 0000000000000..411492d1a2bab --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/utils.test.tsx @@ -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(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/files/utils.tsx b/x-pack/plugins/cases/public/components/files/utils.tsx new file mode 100644 index 0000000000000..b870c733eb10e --- /dev/null +++ b/x-pack/plugins/cases/public/components/files/utils.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + 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 + ); +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx index 1d6a5ec8ca11a..84dddd64ba61b 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/use_lens_open_visualization.tsx @@ -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', diff --git a/x-pack/plugins/cases/public/components/property_actions/index.tsx b/x-pack/plugins/cases/public/components/property_actions/index.tsx index 4de52d551bf2f..833ace8333d2b 100644 --- a/x-pack/plugins/cases/public/components/property_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/property_actions/index.tsx @@ -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( PropertyActionButton.displayName = 'PropertyActionButton'; export interface PropertyActionsProps { - propertyActions: PropertyActionButtonProps[]; + propertyActions: AttachmentAction[]; customDataTestSubj?: string; } @@ -93,14 +96,17 @@ export const PropertyActions = React.memo( {propertyActions.map((action, key) => ( - onClosePopover(action.onClick)} - customDataTestSubj={customDataTestSubj} - /> + {(action.type === AttachmentActionType.BUTTON && ( + onClosePopover(action.onClick)} + customDataTestSubj={customDataTestSubj} + /> + )) || + (action.type === AttachmentActionType.CUSTOM && action.render())} ))} diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx index db21c2f2100c6..4cf6c9844e948 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx @@ -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: () => ( +