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 (
+ <>
+ setAppState({ query: 'my-query' })}>ButtonText
+
{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 setUrlState({ query: {} })}>ButtonText ;
+ 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 (
+ <>
+ setGlobalState({ time: 'now-15m' })}>GlobalStateButton1
+ setGlobalState({ time: 'now-5y' })}>GlobalStateButton2
+ setAppState({ query: 'the updated query' })}>
+ AppStateButton
+
+ {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>) => (
+ <>
+
+ onDone([{ id: mockedExternalReferenceId, kind, fileJSON: { ...basicFileMock, meta } }])
+ }
+ >
+ {'test'}
+
+ onError({ name: 'upload error name', message: 'upload error message' })}
+ >
+ {'test'}
+
+ validateMetadata(meta)}>
+ {'test'}
+
+ >
+ )
+ );
+
+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: () => (
+
+ ),
+ },
+ ],
+ });
+
+ const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
+ externalReferenceAttachmentTypeRegistry.register(attachment);
+
+ const userAction = getExternalReferenceUserAction();
+ const builder = createCommentUserActionBuilder({
+ ...builderArgs,
+ externalReferenceAttachmentTypeRegistry,
+ caseData: {
+ ...builderArgs.caseData,
+ comments: [externalReferenceAttachment],
+ },
+ userAction,
+ });
+
+ const createdUserAction = builder.build();
+
+ appMockRender.render( );
+
+ const customButton = await screen.findByTestId('my-custom-button');
+
+ expect(customButton).toBeInTheDocument();
+
+ userEvent.click(customButton);
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
it('shows correctly the non visible primary actions', async () => {
const onClick = jest.fn();
const attachment = getExternalReferenceAttachment({
getActions: () => [
- { label: 'My primary button', isPrimary: true, iconType: 'danger', onClick },
- { label: 'My primary 2 button', isPrimary: true, iconType: 'danger', onClick },
- { label: 'My primary 3 button', isPrimary: true, iconType: 'danger', onClick },
+ {
+ type: AttachmentActionType.BUTTON,
+ label: 'My primary button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
+ {
+ type: AttachmentActionType.BUTTON,
+ label: 'My primary 2 button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
+ {
+ type: AttachmentActionType.BUTTON,
+ label: 'My primary 3 button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
],
});
@@ -934,16 +1014,101 @@ describe('createCommentUserActionBuilder', () => {
expect(onClick).toHaveBeenCalled();
});
+ it('hides correctly the default actions', async () => {
+ const onClick = jest.fn();
+
+ const attachment = getExternalReferenceAttachment({
+ getActions: () => [
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My primary button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My button',
+ iconType: 'download',
+ onClick,
+ },
+ ],
+ hideDefaultActions: true,
+ });
+
+ const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
+ externalReferenceAttachmentTypeRegistry.register(attachment);
+
+ const userAction = getExternalReferenceUserAction();
+ const builder = createCommentUserActionBuilder({
+ ...builderArgs,
+ externalReferenceAttachmentTypeRegistry,
+ caseData: {
+ ...builderArgs.caseData,
+ comments: [externalReferenceAttachment],
+ },
+ userAction,
+ });
+
+ const createdUserAction = builder.build();
+ appMockRender.render( );
+
+ expect(screen.getByTestId('comment-externalReference-.test')).toBeInTheDocument();
+ expect(screen.getByLabelText('My primary button')).toBeInTheDocument();
+ expect(screen.getByTestId('property-actions-user-action')).toBeInTheDocument();
+
+ userEvent.click(screen.getByTestId('property-actions-user-action-ellipses'));
+
+ await waitForEuiPopoverOpen();
+
+ // default "Delete attachment" action
+ expect(screen.queryByTestId('property-actions-user-action-trash')).not.toBeInTheDocument();
+ expect(screen.queryByText('Delete attachment')).not.toBeInTheDocument();
+ expect(screen.getByText('My button')).toBeInTheDocument();
+
+ userEvent.click(screen.getByText('My button'), undefined, { skipPointerEventsCheck: true });
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
it('shows correctly the registered primary actions and non-primary actions', async () => {
const onClick = jest.fn();
const attachment = getExternalReferenceAttachment({
getActions: () => [
- { label: 'My button', iconType: 'trash', onClick },
- { label: 'My button 2', iconType: 'download', onClick },
- { label: 'My primary button', isPrimary: true, iconType: 'danger', onClick },
- { label: 'My primary 2 button', isPrimary: true, iconType: 'danger', onClick },
- { label: 'My primary 3 button', isPrimary: true, iconType: 'danger', onClick },
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My button',
+ iconType: 'trash',
+ onClick,
+ },
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My button 2',
+ iconType: 'download',
+ onClick,
+ },
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My primary button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My primary 2 button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My primary 3 button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
],
});
@@ -990,7 +1155,13 @@ describe('createCommentUserActionBuilder', () => {
const attachment = getExternalReferenceAttachment({
getActions: () => [
- { label: 'My primary button', isPrimary: true, iconType: 'danger', onClick },
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My primary button',
+ isPrimary: true,
+ iconType: 'danger',
+ onClick,
+ },
],
});
diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/registered_attachments.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/registered_attachments.tsx
index 714bfda9e3a99..9142a71ed7184 100644
--- a/x-pack/plugins/cases/public/components/user_actions/comment/registered_attachments.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/comment/registered_attachments.tsx
@@ -15,11 +15,17 @@ import React, { Suspense } from 'react';
import { memoize, partition } from 'lodash';
import { EuiCallOut, EuiCode, EuiLoadingSpinner, EuiButtonIcon, EuiFlexItem } from '@elastic/eui';
-import type { AttachmentType } from '../../../client/attachment_framework/types';
+
+import type {
+ AttachmentType,
+ AttachmentViewObject,
+} from '../../../client/attachment_framework/types';
+
+import { AttachmentActionType } from '../../../client/attachment_framework/types';
+import { UserActionTimestamp } from '../timestamp';
import type { AttachmentTypeRegistry } from '../../../../common/registry';
import type { CommentResponse } from '../../../../common/api';
import type { UserActionBuilder, UserActionBuilderArgs } from '../types';
-import { UserActionTimestamp } from '../timestamp';
import type { SnakeToCamelCase } from '../../../../common/types';
import {
ATTACHMENT_NOT_REGISTERED_ERROR,
@@ -44,9 +50,7 @@ type BuilderArgs = Pick<
/**
* Provides a render function for attachment type
*/
-const getAttachmentRenderer = memoize((attachmentType: AttachmentType) => {
- const attachmentViewObject = attachmentType.getAttachmentViewObject();
-
+const getAttachmentRenderer = memoize((attachmentViewObject: AttachmentViewObject) => {
let AttachmentElement: React.ReactElement;
const renderCallback = (props: object) => {
@@ -108,14 +112,15 @@ export const createRegisteredAttachmentUserActionBuilder = <
}
const attachmentType = registry.get(attachmentTypeId);
- const renderer = getAttachmentRenderer(attachmentType);
- const attachmentViewObject = attachmentType.getAttachmentViewObject();
const props = {
...getAttachmentViewProps(),
caseData: { id: caseData.id, title: caseData.title },
};
+ const attachmentViewObject = attachmentType.getAttachmentViewObject(props);
+
+ const renderer = getAttachmentRenderer(attachmentViewObject);
const actions = attachmentViewObject.getActions?.(props) ?? [];
const [primaryActions, nonPrimaryActions] = partition(actions, 'isPrimary');
const visiblePrimaryActions = primaryActions.slice(0, 2);
@@ -133,24 +138,29 @@ export const createRegisteredAttachmentUserActionBuilder = <
timelineAvatar: attachmentViewObject.timelineAvatar,
actions: (
),
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.tsx
index 9885de69bb551..98a91d279499e 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.tsx
@@ -6,6 +6,8 @@
*/
import React, { useMemo } from 'react';
+
+import { AttachmentActionType } from '../../../client/attachment_framework/types';
import { useCasesContext } from '../../cases_context/use_cases_context';
import { DeleteAttachmentConfirmationModal } from '../delete_attachment_confirmation_modal';
import { UserActionPropertyActions } from './property_actions';
@@ -31,8 +33,10 @@ const AlertPropertyActionsComponent: React.FC = ({ isLoading, totalAlerts
...(showRemoveAlertIcon
? [
{
- iconType: 'minusInCircle',
+ type: AttachmentActionType.BUTTON as const,
color: 'danger' as const,
+ disabled: false,
+ iconType: 'minusInCircle',
label: i18n.REMOVE_ALERTS(totalAlerts),
onClick: onModalOpen,
},
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx
index ca310ab5121aa..4d6964da2047c 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx
@@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../../common/mock';
import { createAppMockRenderer } from '../../../common/mock';
import { UserActionPropertyActions } from './property_actions';
+import { AttachmentActionType } from '../../../client/attachment_framework/types';
describe('UserActionPropertyActions', () => {
let appMock: AppMockRenderer;
@@ -20,6 +21,7 @@ describe('UserActionPropertyActions', () => {
isLoading: false,
propertyActions: [
{
+ type: AttachmentActionType.BUTTON as const,
iconType: 'pencil',
label: 'Edit',
onClick,
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx
index 975a8670ab096..4e71bbc581e3f 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx
@@ -7,12 +7,12 @@
import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
-import type { PropertyActionButtonProps } from '../../property_actions';
+import type { AttachmentAction } from '../../../client/attachment_framework/types';
import { PropertyActions } from '../../property_actions';
interface Props {
isLoading: boolean;
- propertyActions: PropertyActionButtonProps[];
+ propertyActions: AttachmentAction[];
customDataTestSubj?: string;
}
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx
index 583dd7d5be416..3f1e33d85d47e 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx
@@ -16,6 +16,7 @@ import {
createAppMockRenderer,
} from '../../../common/mock';
import { RegisteredAttachmentsPropertyActions } from './registered_attachments_property_actions';
+import { AttachmentActionType } from '../../../client/attachment_framework/types';
describe('RegisteredAttachmentsPropertyActions', () => {
let appMock: AppMockRenderer;
@@ -24,6 +25,7 @@ describe('RegisteredAttachmentsPropertyActions', () => {
isLoading: false,
registeredAttachmentActions: [],
onDelete: jest.fn(),
+ hideDefaultActions: false,
};
beforeEach(() => {
@@ -90,6 +92,14 @@ describe('RegisteredAttachmentsPropertyActions', () => {
expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
});
+ it('does not show the property actions when hideDefaultActions is enabled', async () => {
+ const result = appMock.render(
+
+ );
+
+ expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
+ });
+
it('does show the property actions with only delete permissions', async () => {
appMock = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() });
const result = appMock.render( );
@@ -99,7 +109,14 @@ describe('RegisteredAttachmentsPropertyActions', () => {
it('renders correctly registered attachments', async () => {
const onClick = jest.fn();
- const action = [{ label: 'My button', iconType: 'download', onClick }];
+ const action = [
+ {
+ type: AttachmentActionType.BUTTON as const,
+ label: 'My button',
+ iconType: 'download',
+ onClick,
+ },
+ ];
const result = appMock.render(
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.tsx
index d567298adc5fd..becb745e09a24 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.tsx
@@ -6,7 +6,10 @@
*/
import React, { useMemo } from 'react';
+
import type { AttachmentAction } from '../../../client/attachment_framework/types';
+
+import { AttachmentActionType } from '../../../client/attachment_framework/types';
import { useCasesContext } from '../../cases_context/use_cases_context';
import * as i18n from './translations';
import { UserActionPropertyActions } from './property_actions';
@@ -17,12 +20,14 @@ interface Props {
isLoading: boolean;
registeredAttachmentActions: AttachmentAction[];
onDelete: () => void;
+ hideDefaultActions: boolean;
}
const RegisteredAttachmentsPropertyActionsComponent: React.FC = ({
isLoading,
registeredAttachmentActions,
onDelete,
+ hideDefaultActions,
}) => {
const { permissions } = useCasesContext();
const { showDeletionModal, onModalOpen, onConfirm, onCancel } = useDeletePropertyAction({
@@ -30,12 +35,14 @@ const RegisteredAttachmentsPropertyActionsComponent: React.FC = ({
});
const propertyActions = useMemo(() => {
- const showTrashIcon = permissions.delete;
+ const showTrashIcon = permissions.delete && !hideDefaultActions;
return [
...(showTrashIcon
? [
{
+ type: AttachmentActionType.BUTTON as const,
+ disabled: false,
iconType: 'trash',
color: 'danger' as const,
label: i18n.DELETE_ATTACHMENT,
@@ -45,7 +52,7 @@ const RegisteredAttachmentsPropertyActionsComponent: React.FC = ({
: []),
...registeredAttachmentActions,
];
- }, [permissions.delete, onModalOpen, registeredAttachmentActions]);
+ }, [permissions.delete, hideDefaultActions, onModalOpen, registeredAttachmentActions]);
return (
<>
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.tsx
index a791ef534e5dd..c1654d63a6233 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.tsx
@@ -6,6 +6,8 @@
*/
import React, { useMemo } from 'react';
+
+import { AttachmentActionType } from '../../../client/attachment_framework/types';
import { useCasesContext } from '../../cases_context/use_cases_context';
import { useLensOpenVisualization } from '../../markdown_editor/plugins/lens/use_lens_open_visualization';
import * as i18n from './translations';
@@ -48,6 +50,7 @@ const UserCommentPropertyActionsComponent: React.FC = ({
...(showEditPencilIcon
? [
{
+ type: AttachmentActionType.BUTTON as const,
iconType: 'pencil',
label: i18n.EDIT_COMMENT,
onClick: onEdit,
@@ -57,6 +60,7 @@ const UserCommentPropertyActionsComponent: React.FC = ({
...(showQuoteIcon
? [
{
+ type: AttachmentActionType.BUTTON as const,
iconType: 'quote',
label: i18n.QUOTE,
onClick: onQuote,
@@ -66,6 +70,7 @@ const UserCommentPropertyActionsComponent: React.FC = ({
...(showTrashIcon
? [
{
+ type: AttachmentActionType.BUTTON as const,
iconType: 'trash',
color: 'danger' as const,
label: i18n.DELETE_COMMENT,
diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts
index 000c8a2f4634b..b29e45f6f101e 100644
--- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts
+++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts
@@ -162,3 +162,13 @@ export const getCaseConnectors = async (
export const getCaseUsers = async (caseId: string, signal: AbortSignal): Promise =>
Promise.resolve(getCaseUsersMockResponse());
+
+export const deleteFileAttachments = async ({
+ caseId,
+ fileIds,
+ signal,
+}: {
+ caseId: string;
+ fileIds: string[];
+ signal: AbortSignal;
+}): Promise => Promise.resolve(undefined);
diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx
index e4d7626a36402..3f623795c819c 100644
--- a/x-pack/plugins/cases/public/containers/api.test.tsx
+++ b/x-pack/plugins/cases/public/containers/api.test.tsx
@@ -16,6 +16,7 @@ import {
INTERNAL_BULK_CREATE_ATTACHMENTS_URL,
SECURITY_SOLUTION_OWNER,
INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL,
+ INTERNAL_DELETE_FILE_ATTACHMENTS_URL,
} from '../../common/constants';
import {
@@ -37,6 +38,7 @@ import {
postComment,
getCaseConnectors,
getCaseUserActionsStats,
+ deleteFileAttachments,
} from './api';
import {
@@ -59,6 +61,7 @@ import {
caseUserActionsWithRegisteredAttachmentsSnake,
basicPushSnake,
getCaseUserActionsStatsResponse,
+ basicFileMock,
} from './mock';
import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases';
@@ -820,6 +823,30 @@ describe('Cases API', () => {
});
});
+ describe('deleteFileAttachments', () => {
+ beforeEach(() => {
+ fetchMock.mockClear();
+ fetchMock.mockResolvedValue(null);
+ });
+
+ it('should be called with correct url, method, signal and body', async () => {
+ const resp = await deleteFileAttachments({
+ caseId: basicCaseId,
+ fileIds: [basicFileMock.id],
+ signal: abortCtrl.signal,
+ });
+ expect(fetchMock).toHaveBeenCalledWith(
+ INTERNAL_DELETE_FILE_ATTACHMENTS_URL.replace('{case_id}', basicCaseId),
+ {
+ method: 'POST',
+ body: JSON.stringify({ ids: [basicFileMock.id] }),
+ signal: abortCtrl.signal,
+ }
+ );
+ expect(resp).toBe(undefined);
+ });
+ });
+
describe('pushCase', () => {
const connectorId = 'connectorId';
diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts
index 05bef55a2b508..a45bfa3752c91 100644
--- a/x-pack/plugins/cases/public/containers/api.ts
+++ b/x-pack/plugins/cases/public/containers/api.ts
@@ -37,6 +37,7 @@ import type {
import {
CommentType,
getCaseCommentsUrl,
+ getCasesDeleteFileAttachmentsUrl,
getCaseDetailsUrl,
getCaseDetailsMetricsUrl,
getCasePushUrl,
@@ -401,6 +402,22 @@ export const createAttachments = async (
return convertCaseToCamelCase(decodeCaseResponse(response));
};
+export const deleteFileAttachments = async ({
+ caseId,
+ fileIds,
+ signal,
+}: {
+ caseId: string;
+ fileIds: string[];
+ signal: AbortSignal;
+}): Promise => {
+ await KibanaServices.get().http.fetch(getCasesDeleteFileAttachmentsUrl(caseId), {
+ method: 'POST',
+ body: JSON.stringify({ ids: fileIds }),
+ signal,
+ });
+};
+
export const getFeatureIds = async (
query: { registrationContext: string[] },
signal: AbortSignal
diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts
index e700f5064e88d..8799e7b65e1a8 100644
--- a/x-pack/plugins/cases/public/containers/constants.ts
+++ b/x-pack/plugins/cases/public/containers/constants.ts
@@ -23,6 +23,9 @@ export const casesQueriesKeys = {
cases: (params: unknown) => [...casesQueriesKeys.casesList(), 'all-cases', params] as const,
caseView: () => [...casesQueriesKeys.all, 'case'] as const,
case: (id: string) => [...casesQueriesKeys.caseView(), id] as const,
+ caseFiles: (id: string, params: unknown) =>
+ [...casesQueriesKeys.case(id), 'files', params] as const,
+ caseFileStats: (id: string) => [...casesQueriesKeys.case(id), 'files', 'stats'] as const,
caseMetrics: (id: string, features: SingleCaseMetricsFeature[]) =>
[...casesQueriesKeys.case(id), 'metrics', features] as const,
caseConnectors: (id: string) => [...casesQueriesKeys.case(id), 'connectors'],
@@ -50,4 +53,5 @@ export const casesMutationsKeys = {
deleteCases: ['delete-cases'] as const,
updateCases: ['update-cases'] as const,
deleteComment: ['delete-comment'] as const,
+ deleteFileAttachment: ['delete-file-attachment'] as const,
};
diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts
index 34552478fcd55..a4192513d7bec 100644
--- a/x-pack/plugins/cases/public/containers/mock.ts
+++ b/x-pack/plugins/cases/public/containers/mock.ts
@@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
+import type { FileJSON } from '@kbn/shared-ux-file-types';
import type { ActionLicense, Cases, Case, CasesStatus, CaseUserActions, Comment } from './types';
@@ -240,6 +241,20 @@ export const basicCase: Case = {
assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }],
};
+export const basicFileMock: FileJSON = {
+ id: '7d47d130-bcec-11ed-afa1-0242ac120002',
+ name: 'my-super-cool-screenshot',
+ mimeType: 'image/png',
+ created: basicCreatedAt,
+ updated: basicCreatedAt,
+ size: 999,
+ meta: '',
+ alt: '',
+ fileKind: '',
+ status: 'READY',
+ extension: 'png',
+};
+
export const caseWithAlerts = {
...basicCase,
totalAlerts: 2,
diff --git a/x-pack/plugins/cases/public/containers/translations.ts b/x-pack/plugins/cases/public/containers/translations.ts
index 6cbc76bb8b361..5d83ea9330ebc 100644
--- a/x-pack/plugins/cases/public/containers/translations.ts
+++ b/x-pack/plugins/cases/public/containers/translations.ts
@@ -21,6 +21,10 @@ export const ERROR_DELETING = i18n.translate('xpack.cases.containers.errorDeleti
defaultMessage: 'Error deleting data',
});
+export const ERROR_DELETING_FILE = i18n.translate('xpack.cases.containers.errorDeletingFile', {
+ defaultMessage: 'Error deleting file',
+});
+
export const ERROR_UPDATING = i18n.translate('xpack.cases.containers.errorUpdatingTitle', {
defaultMessage: 'Error updating data',
});
@@ -53,3 +57,7 @@ export const STATUS_CHANGED_TOASTER_TEXT = i18n.translate(
defaultMessage: 'Updated the statuses of attached alerts.',
}
);
+
+export const FILE_DELETE_SUCCESS = i18n.translate('xpack.cases.containers.deleteSuccess', {
+ defaultMessage: 'File deleted successfully',
+});
diff --git a/x-pack/plugins/cases/public/containers/use_delete_file_attachment.test.tsx b/x-pack/plugins/cases/public/containers/use_delete_file_attachment.test.tsx
new file mode 100644
index 0000000000000..b738f2b50febd
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_delete_file_attachment.test.tsx
@@ -0,0 +1,120 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { act, renderHook } from '@testing-library/react-hooks';
+import * as api from './api';
+import { basicCaseId, basicFileMock } from './mock';
+import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page';
+import { useToasts } from '../common/lib/kibana';
+import type { AppMockRenderer } from '../common/mock';
+import { createAppMockRenderer } from '../common/mock';
+import { useDeleteFileAttachment } from './use_delete_file_attachment';
+
+jest.mock('./api');
+jest.mock('../common/lib/kibana');
+jest.mock('../components/case_view/use_on_refresh_case_view_page');
+
+describe('useDeleteFileAttachment', () => {
+ const addSuccess = jest.fn();
+ const addError = jest.fn();
+
+ (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
+
+ let appMockRender: AppMockRenderer;
+
+ beforeEach(() => {
+ appMockRender = createAppMockRenderer();
+ jest.clearAllMocks();
+ });
+
+ it('calls deleteFileAttachment with correct arguments - case', async () => {
+ const spyOnDeleteFileAttachments = jest.spyOn(api, 'deleteFileAttachments');
+
+ const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
+ wrapper: appMockRender.AppWrapper,
+ });
+
+ act(() => {
+ result.current.mutate({
+ caseId: basicCaseId,
+ fileId: basicFileMock.id,
+ });
+ });
+
+ await waitForNextUpdate();
+
+ expect(spyOnDeleteFileAttachments).toHaveBeenCalledWith({
+ caseId: basicCaseId,
+ fileIds: [basicFileMock.id],
+ signal: expect.any(AbortSignal),
+ });
+ });
+
+ it('refreshes the case page view', async () => {
+ const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
+ wrapper: appMockRender.AppWrapper,
+ });
+
+ act(() =>
+ result.current.mutate({
+ caseId: basicCaseId,
+ fileId: basicFileMock.id,
+ })
+ );
+
+ await waitForNextUpdate();
+
+ expect(useRefreshCaseViewPage()).toBeCalled();
+ });
+
+ it('shows a success toaster correctly', async () => {
+ const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
+ wrapper: appMockRender.AppWrapper,
+ });
+
+ act(() =>
+ result.current.mutate({
+ caseId: basicCaseId,
+ fileId: basicFileMock.id,
+ })
+ );
+
+ await waitForNextUpdate();
+
+ expect(addSuccess).toHaveBeenCalledWith({
+ title: 'File deleted successfully',
+ className: 'eui-textBreakWord',
+ });
+ });
+
+ it('sets isError when fails to delete a file attachment', async () => {
+ const spyOnDeleteFileAttachments = jest.spyOn(api, 'deleteFileAttachments');
+ spyOnDeleteFileAttachments.mockRejectedValue(new Error('Error'));
+
+ const { waitForNextUpdate, result } = renderHook(() => useDeleteFileAttachment(), {
+ wrapper: appMockRender.AppWrapper,
+ });
+
+ act(() =>
+ result.current.mutate({
+ caseId: basicCaseId,
+ fileId: basicFileMock.id,
+ })
+ );
+
+ await waitForNextUpdate();
+
+ expect(spyOnDeleteFileAttachments).toBeCalledWith({
+ caseId: basicCaseId,
+ fileIds: [basicFileMock.id],
+ signal: expect.any(AbortSignal),
+ });
+
+ expect(addError).toHaveBeenCalled();
+ expect(result.current.isError).toBe(true);
+ });
+});
diff --git a/x-pack/plugins/cases/public/containers/use_delete_file_attachment.tsx b/x-pack/plugins/cases/public/containers/use_delete_file_attachment.tsx
new file mode 100644
index 0000000000000..8f6c0effb7b2c
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_delete_file_attachment.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMutation } from '@tanstack/react-query';
+import { casesMutationsKeys } from './constants';
+import type { ServerError } from '../types';
+import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page';
+import { useCasesToast } from '../common/use_cases_toast';
+import { deleteFileAttachments } from './api';
+import * as i18n from './translations';
+
+interface MutationArgs {
+ caseId: string;
+ fileId: string;
+}
+
+export const useDeleteFileAttachment = () => {
+ const { showErrorToast, showSuccessToast } = useCasesToast();
+ const refreshAttachmentsTable = useRefreshCaseViewPage();
+
+ return useMutation(
+ ({ caseId, fileId }: MutationArgs) => {
+ const abortCtrlRef = new AbortController();
+ return deleteFileAttachments({ caseId, fileIds: [fileId], signal: abortCtrlRef.signal });
+ },
+ {
+ mutationKey: casesMutationsKeys.deleteFileAttachment,
+ onSuccess: () => {
+ showSuccessToast(i18n.FILE_DELETE_SUCCESS);
+ refreshAttachmentsTable();
+ },
+ onError: (error: ServerError) => {
+ showErrorToast(error, { title: i18n.ERROR_DELETING_FILE });
+ },
+ }
+ );
+};
+
+export type UseDeleteFileAttachment = ReturnType;
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_file_stats.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_file_stats.test.tsx
new file mode 100644
index 0000000000000..e7c55cd1fc0c5
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_get_case_file_stats.test.tsx
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { renderHook } from '@testing-library/react-hooks';
+
+import { basicCase } from './mock';
+
+import type { AppMockRenderer } from '../common/mock';
+import { mockedTestProvidersOwner, createAppMockRenderer } from '../common/mock';
+import { useToasts } from '../common/lib/kibana';
+import { useGetCaseFileStats } from './use_get_case_file_stats';
+import { constructFileKindIdByOwner } from '../../common/files';
+
+jest.mock('../common/lib/kibana');
+
+const hookParams = {
+ caseId: basicCase.id,
+};
+
+const expectedCallParams = {
+ kind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
+ page: 1,
+ perPage: 1,
+ meta: { caseIds: [hookParams.caseId] },
+};
+
+describe('useGetCaseFileStats', () => {
+ let appMockRender: AppMockRenderer;
+
+ beforeEach(() => {
+ appMockRender = createAppMockRenderer();
+ jest.clearAllMocks();
+ });
+
+ it('calls filesClient.list with correct arguments', async () => {
+ const { waitForNextUpdate } = renderHook(() => useGetCaseFileStats(hookParams), {
+ wrapper: appMockRender.AppWrapper,
+ });
+ await waitForNextUpdate();
+
+ expect(appMockRender.getFilesClient().list).toHaveBeenCalledWith(expectedCallParams);
+ });
+
+ it('shows an error toast when filesClient.list throws', async () => {
+ const addError = jest.fn();
+ (useToasts as jest.Mock).mockReturnValue({ addError });
+
+ appMockRender.getFilesClient().list = jest.fn().mockImplementation(() => {
+ throw new Error('Something went wrong');
+ });
+
+ const { waitForNextUpdate } = renderHook(() => useGetCaseFileStats(hookParams), {
+ wrapper: appMockRender.AppWrapper,
+ });
+ await waitForNextUpdate();
+
+ expect(appMockRender.getFilesClient().list).toHaveBeenCalledWith(expectedCallParams);
+ expect(addError).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_file_stats.tsx b/x-pack/plugins/cases/public/containers/use_get_case_file_stats.tsx
new file mode 100644
index 0000000000000..dd444a5bbb5e9
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_get_case_file_stats.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 type { UseQueryResult } from '@tanstack/react-query';
+
+import type { FileJSON } from '@kbn/shared-ux-file-types';
+
+import { useFilesContext } from '@kbn/shared-ux-file-context';
+import { useQuery } from '@tanstack/react-query';
+
+import type { Owner } from '../../common/constants/types';
+import type { ServerError } from '../types';
+
+import { constructFileKindIdByOwner } from '../../common/files';
+import { useCasesToast } from '../common/use_cases_toast';
+import { useCasesContext } from '../components/cases_context/use_cases_context';
+import { casesQueriesKeys } from './constants';
+import * as i18n from './translations';
+
+const getTotalFromFileList = (data: { files: FileJSON[]; total: number }): { total: number } => ({
+ total: data.total,
+});
+
+export interface GetCaseFileStatsParams {
+ caseId: string;
+}
+
+export const useGetCaseFileStats = ({
+ caseId,
+}: GetCaseFileStatsParams): UseQueryResult<{ total: number }> => {
+ const { owner } = useCasesContext();
+ const { showErrorToast } = useCasesToast();
+ const { client: filesClient } = useFilesContext();
+
+ return useQuery(
+ casesQueriesKeys.caseFileStats(caseId),
+ () => {
+ return filesClient.list({
+ kind: constructFileKindIdByOwner(owner[0] as Owner),
+ page: 1,
+ perPage: 1,
+ meta: { caseIds: [caseId] },
+ });
+ },
+ {
+ select: getTotalFromFileList,
+ keepPreviousData: true,
+ onError: (error: ServerError) => {
+ showErrorToast(error, { title: i18n.ERROR_TITLE });
+ },
+ }
+ );
+};
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx
new file mode 100644
index 0000000000000..712dcb5487c5c
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_get_case_files.test.tsx
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { renderHook, act } from '@testing-library/react-hooks';
+
+import { basicCase } from './mock';
+
+import type { AppMockRenderer } from '../common/mock';
+import { mockedTestProvidersOwner, createAppMockRenderer } from '../common/mock';
+import { useToasts } from '../common/lib/kibana';
+import { useGetCaseFiles } from './use_get_case_files';
+import { constructFileKindIdByOwner } from '../../common/files';
+
+jest.mock('../common/lib/kibana');
+
+const hookParams = {
+ caseId: basicCase.id,
+ page: 1,
+ perPage: 1,
+ searchTerm: 'foobar',
+};
+
+const expectedCallParams = {
+ kind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
+ page: hookParams.page + 1,
+ name: `*${hookParams.searchTerm}*`,
+ perPage: hookParams.perPage,
+ meta: { caseIds: [hookParams.caseId] },
+};
+
+describe('useGetCaseFiles', () => {
+ let appMockRender: AppMockRenderer;
+
+ beforeEach(() => {
+ appMockRender = createAppMockRenderer();
+ jest.clearAllMocks();
+ });
+
+ it('shows an error toast when filesClient.list throws', async () => {
+ const addError = jest.fn();
+ (useToasts as jest.Mock).mockReturnValue({ addError });
+
+ appMockRender.getFilesClient().list = jest.fn().mockImplementation(() => {
+ throw new Error('Something went wrong');
+ });
+
+ const { waitForNextUpdate } = renderHook(() => useGetCaseFiles(hookParams), {
+ wrapper: appMockRender.AppWrapper,
+ });
+ await waitForNextUpdate();
+
+ expect(appMockRender.getFilesClient().list).toBeCalledWith(expectedCallParams);
+ expect(addError).toHaveBeenCalled();
+ });
+
+ it('calls filesClient.list with correct arguments', async () => {
+ await act(async () => {
+ const { waitForNextUpdate } = renderHook(() => useGetCaseFiles(hookParams), {
+ wrapper: appMockRender.AppWrapper,
+ });
+ await waitForNextUpdate();
+
+ expect(appMockRender.getFilesClient().list).toBeCalledWith(expectedCallParams);
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_files.tsx b/x-pack/plugins/cases/public/containers/use_get_case_files.tsx
new file mode 100644
index 0000000000000..88fca6d201285
--- /dev/null
+++ b/x-pack/plugins/cases/public/containers/use_get_case_files.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { FileJSON } from '@kbn/shared-ux-file-types';
+import type { UseQueryResult } from '@tanstack/react-query';
+
+import { useFilesContext } from '@kbn/shared-ux-file-context';
+import { useQuery } from '@tanstack/react-query';
+
+import type { Owner } from '../../common/constants/types';
+import type { ServerError } from '../types';
+
+import { constructFileKindIdByOwner } from '../../common/files';
+import { useCasesToast } from '../common/use_cases_toast';
+import { casesQueriesKeys } from './constants';
+import * as i18n from './translations';
+import { useCasesContext } from '../components/cases_context/use_cases_context';
+
+export interface CaseFilesFilteringOptions {
+ page: number;
+ perPage: number;
+ searchTerm?: string;
+}
+
+export interface GetCaseFilesParams extends CaseFilesFilteringOptions {
+ caseId: string;
+}
+
+export const useGetCaseFiles = ({
+ caseId,
+ page,
+ perPage,
+ searchTerm,
+}: GetCaseFilesParams): UseQueryResult<{ files: FileJSON[]; total: number }> => {
+ const { owner } = useCasesContext();
+ const { showErrorToast } = useCasesToast();
+ const { client: filesClient } = useFilesContext();
+
+ return useQuery(
+ casesQueriesKeys.caseFiles(caseId, { page, perPage, searchTerm }),
+ () => {
+ return filesClient.list({
+ kind: constructFileKindIdByOwner(owner[0] as Owner),
+ page: page + 1,
+ ...(searchTerm && { name: `*${searchTerm}*` }),
+ perPage,
+ meta: { caseIds: [caseId] },
+ });
+ },
+ {
+ keepPreviousData: true,
+ onError: (error: ServerError) => {
+ showErrorToast(error, { title: i18n.ERROR_TITLE });
+ },
+ }
+ );
+};
diff --git a/x-pack/plugins/cases/public/files/index.ts b/x-pack/plugins/cases/public/files/index.ts
index 30a406c788f63..1804e11b0ea17 100644
--- a/x-pack/plugins/cases/public/files/index.ts
+++ b/x-pack/plugins/cases/public/files/index.ts
@@ -20,6 +20,9 @@ const buildFileKind = (config: FilesConfig, owner: Owner): FileKindBrowser => {
};
};
+export const isRegisteredOwner = (ownerToCheck: string): ownerToCheck is Owner =>
+ OWNERS.includes(ownerToCheck as Owner);
+
/**
* The file kind definition for interacting with the file service for the UI
*/
diff --git a/x-pack/plugins/cases/public/internal_attachments/index.ts b/x-pack/plugins/cases/public/internal_attachments/index.ts
new file mode 100644
index 0000000000000..c8457d9a16a1b
--- /dev/null
+++ b/x-pack/plugins/cases/public/internal_attachments/index.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { ExternalReferenceAttachmentTypeRegistry } from '../client/attachment_framework/external_reference_registry';
+import { getFileType } from '../components/files/file_type';
+
+export const registerInternalAttachments = (
+ externalRefRegistry: ExternalReferenceAttachmentTypeRegistry
+) => {
+ externalRefRegistry.register(getFileType());
+};
diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts
index b1ab9f32700dd..8897b9ccbd046 100644
--- a/x-pack/plugins/cases/public/plugin.ts
+++ b/x-pack/plugins/cases/public/plugin.ts
@@ -28,6 +28,7 @@ import { getUICapabilities } from './client/helpers/capabilities';
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
import { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry';
import { registerCaseFileKinds } from './files';
+import { registerInternalAttachments } from './internal_attachments';
/**
* @public
@@ -53,6 +54,7 @@ export class CasesUiPlugin
const externalReferenceAttachmentTypeRegistry = this.externalReferenceAttachmentTypeRegistry;
const persistableStateAttachmentTypeRegistry = this.persistableStateAttachmentTypeRegistry;
+ registerInternalAttachments(externalReferenceAttachmentTypeRegistry);
const config = this.initializerContext.config.get();
registerCaseFileKinds(config.files, plugins.files);
@@ -122,6 +124,7 @@ export class CasesUiPlugin
const getCasesContext = getCasesContextLazy({
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
});
return {
@@ -132,6 +135,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
getCasesContext,
getRecentCases: (props) =>
@@ -139,6 +143,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
// @deprecated Please use the hook useCasesAddToNewCaseFlyout
getCreateCaseFlyout: (props) =>
@@ -146,6 +151,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
// @deprecated Please use the hook useCasesAddToExistingCaseModal
getAllCasesSelectorModal: (props) =>
@@ -153,6 +159,7 @@ export class CasesUiPlugin
...props,
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
+ getFilesClient: plugins.files.filesClientFactory.asScoped,
}),
},
hooks: {
diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts
index e2cb59095f849..6b88c79abb6a3 100644
--- a/x-pack/plugins/cases/public/types.ts
+++ b/x-pack/plugins/cases/public/types.ts
@@ -35,6 +35,7 @@ import type {
CasesStatusRequest,
CommentRequestAlertType,
CommentRequestExternalReferenceNoSOType,
+ CommentRequestExternalReferenceSOType,
CommentRequestPersistableStateType,
CommentRequestUserType,
} from '../common/api';
@@ -167,7 +168,8 @@ export type SupportedCaseAttachment =
| CommentRequestAlertType
| CommentRequestUserType
| CommentRequestPersistableStateType
- | CommentRequestExternalReferenceNoSOType;
+ | CommentRequestExternalReferenceNoSOType
+ | CommentRequestExternalReferenceSOType;
export type CaseAttachments = SupportedCaseAttachment[];
export type CaseAttachmentWithoutOwner = DistributiveOmit;
diff --git a/x-pack/plugins/cases/server/common/limiter_checker/test_utils.ts b/x-pack/plugins/cases/server/common/limiter_checker/test_utils.ts
index 6fa5e0cbbe561..1f0423dc323f5 100644
--- a/x-pack/plugins/cases/server/common/limiter_checker/test_utils.ts
+++ b/x-pack/plugins/cases/server/common/limiter_checker/test_utils.ts
@@ -39,7 +39,7 @@ export const createFileRequests = ({
const files: FileAttachmentMetadata['files'] = [...Array(numFiles).keys()].map((value) => {
return {
name: `${value}`,
- createdAt: '2023-02-27T20:26:54.345Z',
+ created: '2023-02-27T20:26:54.345Z',
extension: 'png',
mimeType: 'image/png',
};
diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json
index 7c703b9819300..3dd670e260f9d 100644
--- a/x-pack/plugins/cases/tsconfig.json
+++ b/x-pack/plugins/cases/tsconfig.json
@@ -57,8 +57,12 @@
"@kbn/shared-ux-router",
"@kbn/files-plugin",
"@kbn/shared-ux-file-types",
+ "@kbn/shared-ux-file-context",
+ "@kbn/shared-ux-file-upload",
+ "@kbn/shared-ux-file-mocks",
"@kbn/saved-objects-finder-plugin",
"@kbn/saved-objects-management-plugin",
+ "@kbn/utility-types-jest",
],
"exclude": [
"target/**/*",
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_indices_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_indices_api_logic.ts
index c9764d7751f73..dbb434032e79f 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_indices_api_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_indices_api_logic.ts
@@ -30,7 +30,7 @@ export const fetchIndices = async ({
const { http } = HttpLogic.values;
const route = '/internal/enterprise_search/indices';
const query = {
- page: 1,
+ from: 0,
return_hidden_indices: false,
search_query: searchQuery || null,
size: INDEX_SEARCH_PAGE_SIZE,
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview.tsx
deleted file mode 100644
index f42b281522adc..0000000000000
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * 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 { useValues } from 'kea';
-
-import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiStat, useEuiTheme } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-
-import { generateEncodedPath } from '../../../shared/encode_path_params';
-import { EuiLinkTo } from '../../../shared/react_router_helpers';
-import { EngineViewTabs, ENGINE_TAB_PATH } from '../../routes';
-import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_template';
-
-import { EngineOverviewLogic } from './engine_overview_logic';
-import { EngineViewHeaderActions } from './engine_view_header_actions';
-
-export const EngineOverview: React.FC = () => {
- const {
- euiTheme: { colors: colors },
- } = useEuiTheme();
- const {
- documentsCount,
- engineName,
- fieldsCount,
- hasUnknownIndices,
- indicesCount,
- isLoadingEngine,
- } = useValues(EngineOverviewLogic);
-
- return (
- ],
- }}
- engineName={engineName}
- >
- <>
-
-
-
-
-
- {hasUnknownIndices ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
-
- );
-};
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.test.ts
deleted file mode 100644
index ebd7e90f7e188..0000000000000
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.test.ts
+++ /dev/null
@@ -1,487 +0,0 @@
-/*
- * 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 { LogicMounter } from '../../../__mocks__/kea_logic';
-
-import { Status } from '../../../../../common/types/api';
-import { EnterpriseSearchEngineIndex, SchemaField } from '../../../../../common/types/engines';
-
-import {
- EngineOverviewLogic,
- EngineOverviewValues,
- selectDocumentsCount,
- selectFieldsCount,
- selectHasUnknownIndices,
- selectIndices,
- selectIndicesCount,
-} from './engine_overview_logic';
-
-const DEFAULT_VALUES: EngineOverviewValues = {
- documentsCount: 0,
- engineData: undefined,
- engineFieldCapabilitiesApiStatus: Status.IDLE,
- engineFieldCapabilitiesData: undefined,
- engineName: '',
- fieldsCount: 0,
- hasUnknownIndices: false,
- indices: [],
- indicesCount: 0,
- isLoadingEngine: true,
-};
-
-describe('EngineOverviewLogic', () => {
- const { mount } = new LogicMounter(EngineOverviewLogic);
-
- beforeEach(() => {
- jest.clearAllMocks();
- jest.useRealTimers();
-
- mount();
- });
-
- it('has expected default values', () => {
- expect(EngineOverviewLogic.values).toEqual(DEFAULT_VALUES);
- });
-
- describe('listeners', () => {
- describe('setEngineName', () => {
- it('refetches the engine field capabilities', () => {
- jest.spyOn(EngineOverviewLogic.actions, 'fetchEngineFieldCapabilities');
-
- EngineOverviewLogic.actions.setEngineName('foobar');
-
- expect(EngineOverviewLogic.actions.fetchEngineFieldCapabilities).toHaveBeenCalledTimes(1);
- expect(EngineOverviewLogic.actions.fetchEngineFieldCapabilities).toHaveBeenCalledWith({
- engineName: 'foobar',
- });
- });
- });
- });
-
- describe('selectors', () => {
- describe('indices', () => {
- it('is defined', () => {
- expect(selectIndices).toBeDefined();
- });
- it('returns an empty array before engineData is loaded', () => {
- expect(selectIndices(undefined)).toEqual([]);
- });
- it('returns the array of indices', () => {
- const indices = [
- {
- count: 10,
- health: 'green',
- name: 'index-001',
- },
- {
- count: 10,
- health: 'green',
- name: 'index-002',
- },
- ];
- const engineData = {
- indices,
- name: 'foo-engine',
- updated_at_millis: 2202018295,
- } as EngineOverviewValues['engineData'];
- expect(selectIndices(engineData)).toBe(indices);
- });
- });
- describe('indicesCount', () => {
- it('is defined', () => {
- expect(selectIndicesCount).toBeDefined();
- });
- it('returns the number of indices', () => {
- const noIndices: EnterpriseSearchEngineIndex[] = [];
- const oneIndex = [
- { count: 23, health: 'unknown', name: 'index-001' },
- ] as EnterpriseSearchEngineIndex[];
- const twoIndices = [
- { count: 23, health: 'unknown', name: 'index-001' },
- { count: 92, health: 'unknown', name: 'index-002' },
- ] as EnterpriseSearchEngineIndex[];
-
- expect(selectIndicesCount(noIndices)).toBe(0);
- expect(selectIndicesCount(oneIndex)).toBe(1);
- expect(selectIndicesCount(twoIndices)).toBe(2);
- });
- });
-
- describe('hasUnknownIndices', () => {
- it('is defined', () => {
- expect(selectHasUnknownIndices).toBeDefined();
- });
- describe('no indices', () => {
- const indices: EnterpriseSearchEngineIndex[] = [];
- it('returns false', () => {
- expect(selectHasUnknownIndices(indices)).toBe(false);
- });
- });
- describe('all indices unknown', () => {
- const indices = [
- {
- count: 12,
- health: 'unknown',
- name: 'index-001',
- },
- {
- count: 34,
- health: 'unknown',
- name: 'index-002',
- },
- {
- count: 56,
- health: 'unknown',
- name: 'index-003',
- },
- ] as EnterpriseSearchEngineIndex[];
- it('returns true', () => {
- expect(selectHasUnknownIndices(indices)).toBe(true);
- });
- });
-
- describe('one index unknown', () => {
- const indices = [
- {
- count: 12,
- health: 'unknown',
- name: 'index-001',
- },
- {
- count: 34,
- health: 'yellow',
- name: 'index-002',
- },
- {
- count: 56,
- health: 'green',
- name: 'index-003',
- },
- ] as EnterpriseSearchEngineIndex[];
- it('returns true', () => {
- expect(selectHasUnknownIndices(indices)).toBe(true);
- });
- });
-
- describe('multiple but not all indices unknown', () => {
- const indices = [
- {
- count: 12,
- health: 'unknown',
- name: 'index-001',
- },
- {
- count: 34,
- health: 'yellow',
- name: 'index-002',
- },
- {
- count: 56,
- health: 'unknown',
- name: 'index-003',
- },
- ] as EnterpriseSearchEngineIndex[];
- it('returns true', () => {
- expect(selectHasUnknownIndices(indices)).toBe(true);
- });
- });
-
- describe('no indices unknown', () => {
- const indices = [
- {
- count: 12,
- health: 'green',
- name: 'index-001',
- },
- {
- count: 34,
- health: 'yellow',
- name: 'index-002',
- },
- {
- count: 56,
- health: 'green',
- name: 'index-003',
- },
- ] as EnterpriseSearchEngineIndex[];
- it('returns false', () => {
- expect(selectHasUnknownIndices(indices)).toBe(false);
- });
- });
- });
-
- describe('documentsCount', () => {
- it('is defined', () => {
- expect(selectDocumentsCount).toBeDefined();
- });
-
- it('returns 0 for no indices', () => {
- expect(selectDocumentsCount([])).toBe(0);
- });
-
- it('returns the `count` for a single index', () => {
- expect(
- selectDocumentsCount([
- {
- count: 23,
- health: 'green',
- name: 'index-001',
- },
- ] as EnterpriseSearchEngineIndex[])
- ).toBe(23);
- });
-
- it('returns the sum of all `count`', () => {
- expect(
- selectDocumentsCount([
- {
- count: 23,
- health: 'green',
- name: 'index-001',
- },
- {
- count: 45,
- health: 'green',
- name: 'index-002',
- },
- ] as EnterpriseSearchEngineIndex[])
- ).toBe(68);
- });
-
- it('does not count indices without a `count`', () => {
- expect(
- selectDocumentsCount([
- {
- count: 23,
- health: 'green',
- name: 'index-001',
- },
- {
- count: null,
- health: 'unknown',
- name: 'index-002',
- },
- {
- count: 45,
- health: 'green',
- name: 'index-002',
- },
- ] as EnterpriseSearchEngineIndex[])
- ).toBe(68);
- });
- });
-
- describe('fieldsCount', () => {
- it('is defined', () => {
- expect(selectFieldsCount).toBeDefined();
- });
- it('counts the fields from the field capabilities', () => {
- const fieldCapabilities = {
- created: '2023-02-07T19:16:43Z',
- fields: [
- {
- indices: [
- {
- name: 'index-001',
- type: 'integer',
- },
- {
- name: 'index-002',
- type: 'integer',
- },
- ],
- name: 'age',
- type: 'integer',
- },
- {
- indices: [
- {
- name: 'index-001',
- type: 'keyword',
- },
- {
- name: 'index-002',
- type: 'keyword',
- },
- ],
- name: 'color',
- type: 'keyword',
- },
- {
- indices: [
- {
- name: 'index-001',
- type: 'text',
- },
- {
- name: 'index-002',
- type: 'text',
- },
- ],
- name: 'name',
- type: 'text',
- },
- ] as SchemaField[],
- name: 'engine-001',
- updated_at_millis: 2202018295,
- };
- expect(selectFieldsCount(fieldCapabilities)).toBe(3);
- });
-
- it('excludes metadata fields from the count', () => {
- const fieldCapabilities = {
- created: '2023-02-07T19:16:43Z',
- fields: [
- {
- aggregatable: true,
- indices: [
- {
- name: 'index-001',
- type: 'integer',
- },
- {
- name: 'index-002',
- type: 'integer',
- },
- ],
- metadata_field: true,
- name: '_doc_count',
- searchable: true,
- type: 'integer',
- },
- {
- aggregatable: true,
- indices: [
- {
- name: 'index-001',
- type: '_id',
- },
- {
- name: 'index-002',
- type: '_id',
- },
- ],
- metadata_field: true,
- name: '_id',
- searchable: true,
- type: '_id',
- },
- {
- aggregatable: true,
- indices: [
- {
- name: 'index-001',
- type: '_index',
- },
- {
- name: 'index-002',
- type: '_index',
- },
- ],
- metadata_field: true,
- name: '_index',
- searchable: true,
- type: '_index',
- },
- {
- aggregatable: true,
- indices: [
- {
- name: 'index-001',
- type: '_source',
- },
- {
- name: 'index-002',
- type: '_source',
- },
- ],
- metadata_field: true,
- name: '_source',
- searchable: true,
- type: '_source',
- },
- {
- aggregatable: true,
- indices: [
- {
- name: 'index-001',
- type: '_version',
- },
- {
- name: 'index-002',
- type: '_version',
- },
- ],
- metadata_field: true,
- name: '_version',
- searchable: true,
- type: '_version',
- },
- {
- aggregatable: true,
- indices: [
- {
- name: 'index-001',
- type: 'integer',
- },
- {
- name: 'index-002',
- type: 'integer',
- },
- ],
- metadata_field: false,
- name: 'age',
- searchable: true,
- type: 'integer',
- },
- {
- aggregatable: true,
- indices: [
- {
- name: 'index-001',
- type: 'keyword',
- },
- {
- name: 'index-002',
- type: 'keyword',
- },
- ],
- metadata_field: false,
- name: 'color',
- searchable: true,
- type: 'keyword',
- },
- {
- aggregatable: false,
- indices: [
- {
- name: 'index-001',
- type: 'text',
- },
- {
- name: 'index-002',
- type: 'text',
- },
- ],
- metadata_field: false,
- name: 'name',
- searchable: true,
- type: 'text',
- },
- ] as SchemaField[],
- name: 'foo-engine',
- updated_at_millis: 2202018295,
- };
- expect(selectFieldsCount(fieldCapabilities)).toBe(3);
- });
-
- it('returns 0 when field capability data is not available', () => {
- expect(selectFieldsCount(undefined)).toBe(0);
- });
- });
- });
-});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.ts
deleted file mode 100644
index 7c6051194919a..0000000000000
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * 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 { kea, MakeLogicType } from 'kea';
-
-import { Status } from '../../../../../common/types/api';
-import { EnterpriseSearchEngineIndex } from '../../../../../common/types/engines';
-
-import { FetchEngineFieldCapabilitiesApiLogic } from '../../api/engines/fetch_engine_field_capabilities_api_logic';
-
-import { EngineNameLogic } from './engine_name_logic';
-import { EngineViewLogic } from './engine_view_logic';
-
-export interface EngineOverviewActions {
- fetchEngineFieldCapabilities: typeof FetchEngineFieldCapabilitiesApiLogic.actions.makeRequest;
- setEngineName: typeof EngineNameLogic.actions.setEngineName;
-}
-export interface EngineOverviewValues {
- documentsCount: number;
- engineData: typeof EngineViewLogic.values.engineData;
- engineFieldCapabilitiesApiStatus: typeof FetchEngineFieldCapabilitiesApiLogic.values.status;
- engineFieldCapabilitiesData: typeof FetchEngineFieldCapabilitiesApiLogic.values.data;
- engineName: typeof EngineNameLogic.values.engineName;
- fieldsCount: number;
- hasUnknownIndices: boolean;
- indices: EnterpriseSearchEngineIndex[];
- indicesCount: number;
- isLoadingEngine: typeof EngineViewLogic.values.isLoadingEngine;
-}
-
-export const selectIndices = (engineData: EngineOverviewValues['engineData']) =>
- engineData?.indices ?? [];
-
-export const selectIndicesCount = (indices: EngineOverviewValues['indices']) => indices.length;
-
-export const selectHasUnknownIndices = (indices: EngineOverviewValues['indices']) =>
- indices.some(({ health }) => health === 'unknown');
-
-export const selectDocumentsCount = (indices: EngineOverviewValues['indices']) =>
- indices.reduce((sum, { count }) => sum + count, 0);
-
-export const selectFieldsCount = (
- engineFieldCapabilitiesData: EngineOverviewValues['engineFieldCapabilitiesData']
-) =>
- engineFieldCapabilitiesData?.fields?.filter(({ metadata_field: isMeta }) => !isMeta).length ?? 0;
-
-export const EngineOverviewLogic = kea>({
- actions: {},
- connect: {
- actions: [
- EngineNameLogic,
- ['setEngineName'],
- FetchEngineFieldCapabilitiesApiLogic,
- ['makeRequest as fetchEngineFieldCapabilities'],
- ],
- values: [
- EngineNameLogic,
- ['engineName'],
- EngineViewLogic,
- ['engineData', 'isLoadingEngine'],
- FetchEngineFieldCapabilitiesApiLogic,
- ['data as engineFieldCapabilitiesData', 'status as engineFieldCapabilitiesApiStatus'],
- ],
- },
- events: ({ actions, values }) => ({
- afterMount: () => {
- if (values.engineFieldCapabilitiesApiStatus !== Status.SUCCESS && !!values.engineName) {
- actions.fetchEngineFieldCapabilities({
- engineName: values.engineName,
- });
- }
- },
- }),
- listeners: ({ actions, values }) => ({
- setEngineName: () => {
- const { engineName } = values;
- actions.fetchEngineFieldCapabilities({ engineName });
- },
- }),
- path: ['enterprise_search', 'content', 'engine_overview_logic'],
- reducers: {},
- selectors: ({ selectors }) => ({
- documentsCount: [() => [selectors.indices], selectDocumentsCount],
- fieldsCount: [() => [selectors.engineFieldCapabilitiesData], selectFieldsCount],
- hasUnknownIndices: [() => [selectors.indices], selectHasUnknownIndices],
- indices: [() => [selectors.engineData], selectIndices],
- indicesCount: [() => [selectors.indices], selectIndicesCount],
- }),
-});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_router.tsx
index 8c93ecc67c1c2..31122af9d8d50 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_router.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_router.tsx
@@ -40,7 +40,7 @@ export const EngineRouter: React.FC = () => {
from={ENGINE_PATH}
to={generateEncodedPath(ENGINE_TAB_PATH, {
engineName,
- tabId: EngineViewTabs.OVERVIEW,
+ tabId: EngineViewTabs.PREVIEW,
})}
exact
/>
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx
index f66a443d62413..f3934c11311a7 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx
@@ -23,7 +23,6 @@ import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_temp
import { EngineAPI } from './engine_api/engine_api';
import { EngineError } from './engine_error';
import { EngineIndices } from './engine_indices';
-import { EngineOverview } from './engine_overview';
import { EngineSchema } from './engine_schema';
import { EngineSearchPreview } from './engine_search_preview/engine_search_preview';
import { EngineViewHeaderActions } from './engine_view_header_actions';
@@ -39,7 +38,7 @@ export const EngineView: React.FC = () => {
isDeleteModalVisible,
isLoadingEngine,
} = useValues(EngineViewLogic);
- const { tabId = EngineViewTabs.OVERVIEW } = useParams<{
+ const { tabId = EngineViewTabs.PREVIEW } = useParams<{
tabId?: string;
}>();
const { renderHeaderActions } = useValues(KibanaLogic);
@@ -73,17 +72,12 @@ export const EngineView: React.FC = () => {
-
(
{
id: 'engineId',
items: [
{
- href: `/app/enterprise_search/content/engines/${engineName}/overview`,
- id: 'enterpriseSearchEngineOverview',
- name: 'Overview',
+ href: `/app/enterprise_search/content/engines/${engineName}/preview`,
+ id: 'enterpriseSearchEnginePreview',
+ name: 'Preview',
},
{
href: `/app/enterprise_search/content/engines/${engineName}/indices`,
@@ -299,11 +299,6 @@ describe('useEnterpriseSearchEngineNav', () => {
id: 'enterpriseSearchEngineSchema',
name: 'Schema',
},
- {
- href: `/app/enterprise_search/content/engines/${engineName}/preview`,
- id: 'enterpriseSearchEnginePreview',
- name: 'Preview',
- },
{
href: `/app/enterprise_search/content/engines/${engineName}/api`,
id: 'enterpriseSearchEngineAPI',
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx
index a6861b6d29e36..0798de9870f94 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx
@@ -193,13 +193,13 @@ export const useEnterpriseSearchEngineNav = (engineName?: string, isEmptyState?:
}),
items: [
{
- id: 'enterpriseSearchEngineOverview',
- name: i18n.translate('xpack.enterpriseSearch.nav.engine.overviewTitle', {
- defaultMessage: 'Overview',
+ id: 'enterpriseSearchEnginePreview',
+ name: i18n.translate('xpack.enterpriseSearch.nav.engine.previewTitle', {
+ defaultMessage: 'Preview',
}),
...generateNavLink({
shouldNotCreateHref: true,
- to: `${enginePath}/${EngineViewTabs.OVERVIEW}`,
+ to: `${enginePath}/${EngineViewTabs.PREVIEW}`,
}),
},
{
@@ -222,16 +222,6 @@ export const useEnterpriseSearchEngineNav = (engineName?: string, isEmptyState?:
to: `${enginePath}/${EngineViewTabs.SCHEMA}`,
}),
},
- {
- id: 'enterpriseSearchEnginePreview',
- name: i18n.translate('xpack.enterpriseSearch.nav.engine.previewTitle', {
- defaultMessage: 'Preview',
- }),
- ...generateNavLink({
- shouldNotCreateHref: true,
- to: `${enginePath}/${EngineViewTabs.PREVIEW}`,
- }),
- },
{
id: 'enterpriseSearchEngineAPI',
name: i18n.translate('xpack.enterpriseSearch.nav.engine.apiTitle', {
diff --git a/x-pack/plugins/observability/public/data/slo/common.ts b/x-pack/plugins/observability/public/data/slo/common.ts
index 3c0e1b7e49408..ae25d150f350b 100644
--- a/x-pack/plugins/observability/public/data/slo/common.ts
+++ b/x-pack/plugins/observability/public/data/slo/common.ts
@@ -35,8 +35,8 @@ export const buildHealthySummary = (
sliValue: 0.99872,
errorBudget: {
initial: 0.02,
- consumed: 0.064,
- remaining: 0.936,
+ consumed: 0.0642,
+ remaining: 0.93623,
isEstimated: false,
},
...params,
@@ -48,11 +48,11 @@ export const buildViolatedSummary = (
): SLOWithSummaryResponse['summary'] => {
return {
status: 'VIOLATED',
- sliValue: 0.97,
+ sliValue: 0.81232,
errorBudget: {
initial: 0.02,
consumed: 1,
- remaining: 0,
+ remaining: -3.1234,
isEstimated: false,
},
...params,
@@ -80,11 +80,11 @@ export const buildDegradingSummary = (
): SLOWithSummaryResponse['summary'] => {
return {
status: 'DEGRADING',
- sliValue: 0.97,
+ sliValue: 0.97982,
errorBudget: {
- initial: 0.02,
- consumed: 0.88,
- remaining: 0.12,
+ initial: 0.01,
+ consumed: 0.8822,
+ remaining: 0.1244,
isEstimated: true,
},
...params,
diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx
index 4f34ea594ddee..8b3586554abf8 100644
--- a/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx
+++ b/x-pack/plugins/observability/public/pages/slos/components/slo_sparkline.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { AreaSeries, Chart, Fit, LineSeries, ScaleType, Settings } from '@elastic/charts';
+import { AreaSeries, Axis, Chart, Fit, LineSeries, ScaleType, Settings } from '@elastic/charts';
import React from 'react';
import { EuiLoadingChart, useEuiTheme } from '@elastic/eui';
import { EUI_SPARKLINE_THEME_PARTIAL } from '@elastic/eui/dist/eui_charts_theme';
@@ -36,28 +36,38 @@ export function SloSparkline({ chart, data, id, isLoading, state }: Props) {
const color = state === 'error' ? euiTheme.colors.danger : euiTheme.colors.success;
const ChartComponent = chart === 'area' ? AreaSeries : LineSeries;
+ const LineAxisComponent =
+ chart === 'line' ? (
+
+ ) : null;
if (isLoading) {
return ;
}
return (
-
+
+ {LineAxisComponent}
-
-
-
+
+
+
+
-
-
-
+
+
+
{
if (setting === 'dateFormat') {
diff --git a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx
index 9102802f5d79e..6ac0e374b3919 100644
--- a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx
+++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content/reporting_panel_content.tsx
@@ -62,6 +62,7 @@ interface State {
absoluteUrl: string;
layoutId: string;
objectType: string;
+ isCreatingReportJob: boolean;
}
class ReportingPanelContentUi extends Component {
@@ -78,6 +79,7 @@ class ReportingPanelContentUi extends Component {
absoluteUrl: this.getAbsoluteReportGenerationUrl(props),
layoutId: '',
objectType,
+ isCreatingReportJob: false,
};
}
@@ -227,12 +229,13 @@ class ReportingPanelContentUi extends Component {
private renderGenerateReportButton = (isDisabled: boolean) => {
return (
{
this.props.getJobParams()
);
+ this.setState({ isCreatingReportJob: true });
+
return this.props.apiClient
.createReportingJob(this.props.reportType, decoratedJobParams)
.then(() => {
@@ -313,6 +318,9 @@ class ReportingPanelContentUi extends Component {
if (this.props.onClose) {
this.props.onClose();
}
+ if (this.mounted) {
+ this.setState({ isCreatingReportJob: false });
+ }
})
.catch((error) => {
this.props.toasts.addError(error, {
@@ -325,6 +333,9 @@ class ReportingPanelContentUi extends Component {
) as unknown as string,
});
+ if (this.mounted) {
+ this.setState({ isCreatingReportJob: false });
+ }
});
};
}
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx
index 97a9ac3e62ce3..17580afbf2cf4 100644
--- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx
+++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/components/permissions.tsx
@@ -100,7 +100,7 @@ const CANNOT_PERFORM_ACTION_FLEET = i18n.translate(
}
);
-const CANNOT_PERFORM_ACTION_SYNTHETICS = i18n.translate(
+export const CANNOT_PERFORM_ACTION_SYNTHETICS = i18n.translate(
'xpack.synthetics.monitorManagement.noSyntheticsPermissions',
{
defaultMessage: 'You do not have sufficient permissions to perform this action.',
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx
index dd56da1e7e563..074695f26c148 100644
--- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx
+++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx
@@ -5,16 +5,20 @@
* 2.0.
*/
-import { EuiBasicTableColumn } from '@elastic/eui';
+import { EuiBasicTableColumn, EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
+import { useCanEditSynthetics } from '../../../../../../hooks/use_capabilities';
import {
isStatusEnabled,
toggleStatusAlert,
} from '../../../../../../../common/runtime_types/monitor_management/alert_config';
-import { NoPermissionsTooltip } from '../../../common/components/permissions';
+import {
+ CANNOT_PERFORM_ACTION_SYNTHETICS,
+ NoPermissionsTooltip,
+} from '../../../common/components/permissions';
import { TagsBadges } from '../../../common/components/tag_badges';
import { useMonitorAlertEnable } from '../../../../hooks/use_monitor_alert_enable';
import * as labels from './labels';
@@ -35,17 +39,16 @@ import { MonitorEnabled } from './monitor_enabled';
import { MonitorLocations } from './monitor_locations';
export function useMonitorListColumns({
- canEditSynthetics,
loading,
overviewStatus,
setMonitorPendingDeletion,
}: {
- canEditSynthetics: boolean;
loading: boolean;
overviewStatus: OverviewStatusState | null;
setMonitorPendingDeletion: (config: EncryptedSyntheticsSavedMonitor) => void;
}): Array> {
const history = useHistory();
+ const canEditSynthetics = useCanEditSynthetics();
const { alertStatus, updateAlertEnabledState } = useMonitorAlertEnable();
const { canSaveIntegrations } = useFleetPermissions();
@@ -54,7 +57,7 @@ export function useMonitorListColumns({
return alertStatus(fields[ConfigKey.CONFIG_ID]) === FETCH_STATUS.LOADING;
};
- return [
+ const columns: Array> = [
{
align: 'left' as const,
field: ConfigKey.NAME as string,
@@ -173,8 +176,8 @@ export function useMonitorListColumns({
),
description: labels.EDIT_LABEL,
- icon: 'pencil',
- type: 'icon',
+ icon: 'pencil' as const,
+ type: 'icon' as const,
enabled: (fields) =>
canEditSynthetics &&
!isActionLoading(fields) &&
@@ -197,9 +200,9 @@ export function useMonitorListColumns({
),
description: labels.DELETE_LABEL,
- icon: 'trash',
- type: 'icon',
- color: 'danger',
+ icon: 'trash' as const,
+ type: 'icon' as const,
+ color: 'danger' as const,
enabled: (fields) =>
canEditSynthetics &&
!isActionLoading(fields) &&
@@ -216,8 +219,8 @@ export function useMonitorListColumns({
: labels.ENABLE_STATUS_ALERT,
icon: (fields) =>
isStatusEnabled(fields[ConfigKey.ALERT_CONFIG]) ? 'bellSlash' : 'bell',
- type: 'icon',
- color: 'danger',
+ type: 'icon' as const,
+ color: 'danger' as const,
enabled: (fields) => canEditSynthetics && !isActionLoading(fields),
onClick: (fields) => {
updateAlertEnabledState({
@@ -240,4 +243,25 @@ export function useMonitorListColumns({
],
},
];
+
+ if (!canEditSynthetics) {
+ // replace last column with a tooltip
+ columns[columns.length - 1] = {
+ align: 'right' as const,
+ name: i18n.translate('xpack.synthetics.management.monitorList.actions', {
+ defaultMessage: 'Actions',
+ }),
+ render: () => (
+
+
+
+ ),
+ };
+ }
+
+ return columns;
}
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx
index 2ac8c0f6129d6..55f2d7e80ee1a 100644
--- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx
+++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_list.tsx
@@ -19,7 +19,6 @@ import { i18n } from '@kbn/i18n';
import { DeleteMonitor } from './delete_monitor';
import { IHttpSerializedFetchError } from '../../../../state/utils/http_error';
import { MonitorListPageState } from '../../../../state';
-import { useCanEditSynthetics } from '../../../../../../hooks/use_capabilities';
import {
ConfigKey,
EncryptedSyntheticsSavedMonitor,
@@ -52,7 +51,6 @@ export const MonitorList = ({
}: Props) => {
const { euiTheme } = useEuiTheme();
const isXl = useIsWithinMinBreakpoint('xxl');
- const canEditSynthetics = useCanEditSynthetics();
const [monitorPendingDeletion, setMonitorPendingDeletion] =
useState(null);
@@ -96,7 +94,6 @@ export const MonitorList = ({
});
const columns = useMonitorListColumns({
- canEditSynthetics,
loading,
overviewStatus,
setMonitorPendingDeletion,
diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json
index 21137b5344e2e..b8e6993bf9d6a 100644
--- a/x-pack/plugins/translations/translations/fr-FR.json
+++ b/x-pack/plugins/translations/translations/fr-FR.json
@@ -12280,10 +12280,6 @@
"xpack.enterpriseSearch.content.engine.indices.removeIndexConfirm.title": "Retirer cet index du moteur",
"xpack.enterpriseSearch.content.engine.indices.searchPlaceholder": "Filtrer les index",
"xpack.enterpriseSearch.content.engine.indicesSelect.docsLabel": "Documents :",
- "xpack.enterpriseSearch.content.engine.overview.documentsDescription": "Documents",
- "xpack.enterpriseSearch.content.engine.overview.fieldsDescription": "Champs",
- "xpack.enterpriseSearch.content.engine.overview.indicesDescription": "Index",
- "xpack.enterpriseSearch.content.engine.overview.pageTitle": "Aperçu",
"xpack.enterpriseSearch.content.engine.schema.field_name.columnTitle": "Nom du champ",
"xpack.enterpriseSearch.content.engine.schema.field_type.columnTitle": "Type du champ",
"xpack.enterpriseSearch.content.engine.schema.pageTitle": "Schéma",
@@ -13190,7 +13186,6 @@
"xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch",
"xpack.enterpriseSearch.nav.engine.apiTitle": "API",
"xpack.enterpriseSearch.nav.engine.indicesTitle": "Index",
- "xpack.enterpriseSearch.nav.engine.overviewTitle": "Aperçu",
"xpack.enterpriseSearch.nav.engine.schemaTitle": "Schéma",
"xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "Aperçu",
"xpack.enterpriseSearch.nav.searchExperiencesTitle": "Expériences de recherche",
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 74b4c94ef5d1c..7d3317df1bf37 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -12279,10 +12279,6 @@
"xpack.enterpriseSearch.content.engine.indices.removeIndexConfirm.title": "このインデックスをエンジンから削除",
"xpack.enterpriseSearch.content.engine.indices.searchPlaceholder": "インデックスのフィルター",
"xpack.enterpriseSearch.content.engine.indicesSelect.docsLabel": "ドキュメント:",
- "xpack.enterpriseSearch.content.engine.overview.documentsDescription": "ドキュメント",
- "xpack.enterpriseSearch.content.engine.overview.fieldsDescription": "フィールド",
- "xpack.enterpriseSearch.content.engine.overview.indicesDescription": "インデックス",
- "xpack.enterpriseSearch.content.engine.overview.pageTitle": "概要",
"xpack.enterpriseSearch.content.engine.schema.field_name.columnTitle": "フィールド名",
"xpack.enterpriseSearch.content.engine.schema.field_type.columnTitle": "フィールド型",
"xpack.enterpriseSearch.content.engine.schema.pageTitle": "スキーマ",
@@ -13189,7 +13185,6 @@
"xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch",
"xpack.enterpriseSearch.nav.engine.apiTitle": "API",
"xpack.enterpriseSearch.nav.engine.indicesTitle": "インデックス",
- "xpack.enterpriseSearch.nav.engine.overviewTitle": "概要",
"xpack.enterpriseSearch.nav.engine.schemaTitle": "スキーマ",
"xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "概要",
"xpack.enterpriseSearch.nav.searchExperiencesTitle": "検索エクスペリエンス",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index d78366da8b8c7..dce328063bf26 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -12280,10 +12280,6 @@
"xpack.enterpriseSearch.content.engine.indices.removeIndexConfirm.title": "从引擎中移除此索引",
"xpack.enterpriseSearch.content.engine.indices.searchPlaceholder": "筛选索引",
"xpack.enterpriseSearch.content.engine.indicesSelect.docsLabel": "文档:",
- "xpack.enterpriseSearch.content.engine.overview.documentsDescription": "文档",
- "xpack.enterpriseSearch.content.engine.overview.fieldsDescription": "字段",
- "xpack.enterpriseSearch.content.engine.overview.indicesDescription": "索引",
- "xpack.enterpriseSearch.content.engine.overview.pageTitle": "概览",
"xpack.enterpriseSearch.content.engine.schema.field_name.columnTitle": "字段名称",
"xpack.enterpriseSearch.content.engine.schema.field_type.columnTitle": "字段类型",
"xpack.enterpriseSearch.content.engine.schema.pageTitle": "架构",
@@ -13190,7 +13186,6 @@
"xpack.enterpriseSearch.nav.elasticsearchTitle": "Elasticsearch",
"xpack.enterpriseSearch.nav.engine.apiTitle": "API",
"xpack.enterpriseSearch.nav.engine.indicesTitle": "索引",
- "xpack.enterpriseSearch.nav.engine.overviewTitle": "概览",
"xpack.enterpriseSearch.nav.engine.schemaTitle": "架构",
"xpack.enterpriseSearch.nav.enterpriseSearchOverviewTitle": "概览",
"xpack.enterpriseSearch.nav.searchExperiencesTitle": "搜索体验",
diff --git a/x-pack/test/cases_api_integration/common/lib/mock.ts b/x-pack/test/cases_api_integration/common/lib/mock.ts
index bb8233dd058da..1ea6c1b1e1359 100644
--- a/x-pack/test/cases_api_integration/common/lib/mock.ts
+++ b/x-pack/test/cases_api_integration/common/lib/mock.ts
@@ -129,7 +129,7 @@ export const fileMetadata = () => ({
name: 'test_file',
extension: 'png',
mimeType: 'image/png',
- createdAt: '2023-02-27T20:26:54.345Z',
+ created: '2023-02-27T20:26:54.345Z',
});
export const fileAttachmentMetadata: FileAttachmentMetadata = {
diff --git a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts
index d34169b05a408..92320dad62087 100644
--- a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts
+++ b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts
@@ -10,7 +10,7 @@ import { orderBy } from 'lodash';
import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../ftr_provider_context';
-import type { TestData } from './types';
+import { isTestDataExpectedWithSampleProbability, type TestData } from './types';
import { explainLogRateSpikesTestData } from './test_data';
export default function ({ getPageObject, getService }: FtrProviderContext) {
@@ -43,9 +43,21 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await aiops.explainLogRateSpikesPage.assertTimeRangeSelectorSectionExists();
await ml.testExecution.logTestStep(`${testData.suiteTitle} loads data for full time range`);
+ if (testData.query) {
+ await aiops.explainLogRateSpikesPage.setQueryInput(testData.query);
+ }
await aiops.explainLogRateSpikesPage.clickUseFullDataButton(
testData.expected.totalDocCountFormatted
);
+
+ if (isTestDataExpectedWithSampleProbability(testData.expected)) {
+ await aiops.explainLogRateSpikesPage.assertSamplingProbability(
+ testData.expected.sampleProbabilityFormatted
+ );
+ } else {
+ await aiops.explainLogRateSpikesPage.assertSamplingProbabilityMissing();
+ }
+
await headerPage.waitUntilLoadingHasFinished();
await ml.testExecution.logTestStep(
@@ -147,21 +159,24 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await aiops.explainLogRateSpikesAnalysisGroupsTable.assertSpikeAnalysisTableExists();
- const analysisGroupsTable =
- await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable();
-
- expect(orderBy(analysisGroupsTable, 'group')).to.be.eql(
- orderBy(testData.expected.analysisGroupsTable, 'group')
- );
+ if (!isTestDataExpectedWithSampleProbability(testData.expected)) {
+ const analysisGroupsTable =
+ await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable();
+ expect(orderBy(analysisGroupsTable, 'group')).to.be.eql(
+ orderBy(testData.expected.analysisGroupsTable, 'group')
+ );
+ }
await ml.testExecution.logTestStep('expand table row');
await aiops.explainLogRateSpikesAnalysisGroupsTable.assertExpandRowButtonExists();
await aiops.explainLogRateSpikesAnalysisGroupsTable.expandRow();
- const analysisTable = await aiops.explainLogRateSpikesAnalysisTable.parseAnalysisTable();
- expect(orderBy(analysisTable, ['fieldName', 'fieldValue'])).to.be.eql(
- orderBy(testData.expected.analysisTable, ['fieldName', 'fieldValue'])
- );
+ if (!isTestDataExpectedWithSampleProbability(testData.expected)) {
+ const analysisTable = await aiops.explainLogRateSpikesAnalysisTable.parseAnalysisTable();
+ expect(orderBy(analysisTable, ['fieldName', 'fieldValue'])).to.be.eql(
+ orderBy(testData.expected.analysisTable, ['fieldName', 'fieldValue'])
+ );
+ }
// Assert the field selector that allows to costumize grouping
await aiops.explainLogRateSpikesPage.assertFieldFilterPopoverButtonExists(false);
@@ -182,11 +197,14 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
if (testData.fieldSelectorApplyAvailable) {
await aiops.explainLogRateSpikesPage.clickFieldFilterApplyButton();
- const filteredAnalysisGroupsTable =
- await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable();
- expect(orderBy(filteredAnalysisGroupsTable, 'group')).to.be.eql(
- orderBy(testData.expected.filteredAnalysisGroupsTable, 'group')
- );
+
+ if (!isTestDataExpectedWithSampleProbability(testData.expected)) {
+ const filteredAnalysisGroupsTable =
+ await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable();
+ expect(orderBy(filteredAnalysisGroupsTable, 'group')).to.be.eql(
+ orderBy(testData.expected.filteredAnalysisGroupsTable, 'group')
+ );
+ }
}
});
}
diff --git a/x-pack/test/functional/apps/aiops/test_data.ts b/x-pack/test/functional/apps/aiops/test_data.ts
index 1ccc441618bdb..b6d3293aeba81 100644
--- a/x-pack/test/functional/apps/aiops/test_data.ts
+++ b/x-pack/test/functional/apps/aiops/test_data.ts
@@ -19,6 +19,24 @@ export const farequoteDataViewTestData: TestData = {
fieldSelectorApplyAvailable: false,
expected: {
totalDocCountFormatted: '86,374',
+ sampleProbabilityFormatted: '0.5',
+ fieldSelectorPopover: ['airline', 'custom_field.keyword'],
+ },
+};
+
+export const farequoteDataViewTestDataWithQuery: TestData = {
+ suiteTitle: 'farequote with spike',
+ dataGenerator: 'farequote_with_spike',
+ isSavedSearch: false,
+ sourceIndexOrSavedSearch: 'ft_farequote',
+ brushDeviationTargetTimestamp: 1455033600000,
+ brushIntervalFactor: 1,
+ chartClickCoordinates: [0, 0],
+ fieldSelectorSearch: 'airline',
+ fieldSelectorApplyAvailable: false,
+ query: 'NOT airline:("SWR" OR "ACA" OR "AWE" OR "BAW" OR "JAL" OR "JBU" OR "JZA" OR "KLM")',
+ expected: {
+ totalDocCountFormatted: '48,799',
analysisGroupsTable: [
{
docCount: '297',
@@ -34,7 +52,7 @@ export const farequoteDataViewTestData: TestData = {
fieldName: 'airline',
fieldValue: 'AAL',
logRate: 'Chart type:bar chart',
- pValue: '4.66e-11',
+ pValue: '1.18e-8',
impact: 'High',
},
],
@@ -105,5 +123,6 @@ export const artificialLogDataViewTestData: TestData = {
export const explainLogRateSpikesTestData: TestData[] = [
farequoteDataViewTestData,
+ farequoteDataViewTestDataWithQuery,
artificialLogDataViewTestData,
];
diff --git a/x-pack/test/functional/apps/aiops/types.ts b/x-pack/test/functional/apps/aiops/types.ts
index 7a758aa4a65ff..01733a8e1a2af 100644
--- a/x-pack/test/functional/apps/aiops/types.ts
+++ b/x-pack/test/functional/apps/aiops/types.ts
@@ -5,6 +5,34 @@
* 2.0.
*/
+import { isPopulatedObject } from '@kbn/ml-is-populated-object';
+
+interface TestDataExpectedWithSampleProbability {
+ totalDocCountFormatted: string;
+ sampleProbabilityFormatted: string;
+ fieldSelectorPopover: string[];
+}
+
+export function isTestDataExpectedWithSampleProbability(
+ arg: unknown
+): arg is TestDataExpectedWithSampleProbability {
+ return isPopulatedObject(arg, ['sampleProbabilityFormatted']);
+}
+
+interface TestDataExpectedWithoutSampleProbability {
+ totalDocCountFormatted: string;
+ analysisGroupsTable: Array<{ group: string; docCount: string }>;
+ filteredAnalysisGroupsTable?: Array<{ group: string; docCount: string }>;
+ analysisTable: Array<{
+ fieldName: string;
+ fieldValue: string;
+ logRate: string;
+ pValue: string;
+ impact: string;
+ }>;
+ fieldSelectorPopover: string[];
+}
+
export interface TestData {
suiteTitle: string;
dataGenerator: string;
@@ -17,17 +45,6 @@ export interface TestData {
chartClickCoordinates: [number, number];
fieldSelectorSearch: string;
fieldSelectorApplyAvailable: boolean;
- expected: {
- totalDocCountFormatted: string;
- analysisGroupsTable: Array<{ group: string; docCount: string }>;
- filteredAnalysisGroupsTable?: Array<{ group: string; docCount: string }>;
- analysisTable: Array<{
- fieldName: string;
- fieldValue: string;
- logRate: string;
- pValue: string;
- impact: string;
- }>;
- fieldSelectorPopover: string[];
- };
+ query?: string;
+ expected: TestDataExpectedWithSampleProbability | TestDataExpectedWithoutSampleProbability;
}
diff --git a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts
index 3a921a74ee359..3da9ed7c760b7 100644
--- a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts
+++ b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_page.ts
@@ -9,12 +9,16 @@ import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../ftr_provider_context';
-export function ExplainLogRateSpikesPageProvider({ getService }: FtrProviderContext) {
+export function ExplainLogRateSpikesPageProvider({
+ getService,
+ getPageObject,
+}: FtrProviderContext) {
const browser = getService('browser');
const elasticChart = getService('elasticChart');
const ml = getService('ml');
const testSubjects = getService('testSubjects');
const retry = getService('retry');
+ const header = getPageObject('header');
return {
async assertTimeRangeSelectorSectionExists() {
@@ -31,6 +35,32 @@ export function ExplainLogRateSpikesPageProvider({ getService }: FtrProviderCont
});
},
+ async assertSamplingProbability(expectedFormattedSamplingProbability: string) {
+ await retry.tryForTime(5000, async () => {
+ const samplingProbability = await testSubjects.getVisibleText('aiopsSamplingProbability');
+ expect(samplingProbability).to.eql(
+ expectedFormattedSamplingProbability,
+ `Expected total document count to be '${expectedFormattedSamplingProbability}' (got '${samplingProbability}')`
+ );
+ });
+ },
+
+ async setQueryInput(query: string) {
+ const aiopsQueryInput = await testSubjects.find('aiopsQueryInput');
+ await aiopsQueryInput.type(query);
+ await aiopsQueryInput.pressKeys(browser.keys.ENTER);
+ await header.waitUntilLoadingHasFinished();
+ const queryBarText = await aiopsQueryInput.getVisibleText();
+ expect(queryBarText).to.eql(
+ query,
+ `Expected query bar text to be '${query}' (got '${queryBarText}')`
+ );
+ },
+
+ async assertSamplingProbabilityMissing() {
+ await testSubjects.missingOrFail('aiopsSamplingProbability');
+ },
+
async clickUseFullDataButton(expectedFormattedTotalDocCount: string) {
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.clickWhenNotDisabledWithoutRetry('mlDatePickerButtonUseFullData');
diff --git a/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/external_reference.tsx b/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/external_reference.tsx
index dae5945a3d687..3ab6cd69103e0 100644
--- a/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/external_reference.tsx
+++ b/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/external_reference.tsx
@@ -6,7 +6,10 @@
*/
import { lazy } from 'react';
-import { ExternalReferenceAttachmentType } from '@kbn/cases-plugin/public/client/attachment_framework/types';
+import type {
+ AttachmentActionType,
+ ExternalReferenceAttachmentType,
+} from '@kbn/cases-plugin/public/client/attachment_framework/types';
const AttachmentContentLazy = lazy(() => import('./external_references_content'));
@@ -18,7 +21,13 @@ export const getExternalReferenceAttachmentRegular = (): ExternalReferenceAttach
event: 'added a chart',
timelineAvatar: 'casesApp',
getActions: () => [
- { label: 'See attachment', onClick: () => {}, isPrimary: true, iconType: 'arrowRight' },
+ {
+ type: 'button' as AttachmentActionType.BUTTON,
+ label: 'See attachment',
+ onClick: () => {},
+ isPrimary: true,
+ iconType: 'arrowRight',
+ },
],
children: AttachmentContentLazy,
}),
diff --git a/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/persistable_state.tsx b/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/persistable_state.tsx
index 2159c9c7b551d..1a27ef03f0d9d 100644
--- a/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/persistable_state.tsx
+++ b/x-pack/test/functional_with_es_ssl/plugins/cases/public/attachments/persistable_state.tsx
@@ -7,7 +7,8 @@
import React from 'react';
-import {
+import type {
+ AttachmentActionType,
PersistableStateAttachmentType,
PersistableStateAttachmentViewProps,
} from '@kbn/cases-plugin/public/client/attachment_framework/types';
@@ -50,7 +51,13 @@ export const getPersistableStateAttachmentRegular = (
event: 'added an embeddable',
timelineAvatar: 'casesApp',
getActions: () => [
- { label: 'See attachment', onClick: () => {}, isPrimary: true, iconType: 'arrowRight' },
+ {
+ type: 'button' as AttachmentActionType.BUTTON,
+ label: 'See attachment',
+ onClick: () => {},
+ isPrimary: true,
+ iconType: 'arrowRight',
+ },
],
children: getLazyComponent(EmbeddableComponent),
}),