+ ).inMemory
+ ).toMatchInlineSnapshot(`
+ Object {
+ "level": "sorting",
+ }
+ `);
+ });
+ });
});
diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx
index 34ddd0c995666..69748b449b05c 100644
--- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx
+++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx
@@ -362,13 +362,18 @@ export const DiscoverGrid = ({
*/
const sortingColumns = useMemo(() => sort.map(([id, direction]) => ({ id, direction })), [sort]);
+ const [inmemorySortingColumns, setInmemorySortingColumns] = useState([]);
const onTableSort = useCallback(
(sortingColumnsData) => {
- if (isSortEnabled && onSort) {
- onSort(sortingColumnsData.map(({ id, direction }: SortObj) => [id, direction]));
+ if (isSortEnabled) {
+ if (isPlainRecord) {
+ setInmemorySortingColumns(sortingColumnsData);
+ } else if (onSort) {
+ onSort(sortingColumnsData.map(({ id, direction }: SortObj) => [id, direction]));
+ }
}
},
- [onSort, isSortEnabled]
+ [onSort, isSortEnabled, isPlainRecord, setInmemorySortingColumns]
);
const showMultiFields = services.uiSettings.get(SHOW_MULTIFIELDS);
@@ -437,6 +442,7 @@ export const DiscoverGrid = ({
showTimeCol,
defaultColumns,
isSortEnabled,
+ isPlainRecord,
services: {
uiSettings,
toastNotifications,
@@ -455,6 +461,7 @@ export const DiscoverGrid = ({
settings,
defaultColumns,
isSortEnabled,
+ isPlainRecord,
uiSettings,
toastNotifications,
dataViewFieldEditor,
@@ -479,10 +486,13 @@ export const DiscoverGrid = ({
);
const sorting = useMemo(() => {
if (isSortEnabled) {
- return { columns: sortingColumns, onSort: onTableSort };
+ return {
+ columns: isPlainRecord ? inmemorySortingColumns : sortingColumns,
+ onSort: onTableSort,
+ };
}
return { columns: sortingColumns, onSort: () => {} };
- }, [sortingColumns, onTableSort, isSortEnabled]);
+ }, [isSortEnabled, sortingColumns, isPlainRecord, inmemorySortingColumns, onTableSort]);
const canSetExpandedDoc = Boolean(setExpandedDoc && DocumentView);
@@ -619,6 +629,7 @@ export const DiscoverGrid = ({
sorting={sorting as EuiDataGridSorting}
toolbarVisibility={toolbarVisibility}
rowHeightsOptions={rowHeightsOptions}
+ inMemory={isPlainRecord ? { level: 'sorting' } : undefined}
gridStyle={GRID_STYLE}
/>
diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx
index fd7122fccbd95..c4c68bf0132d0 100644
--- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx
+++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx
@@ -21,6 +21,7 @@ describe('Discover grid columns', function () {
showTimeCol: false,
defaultColumns: false,
isSortEnabled: true,
+ isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
@@ -140,6 +141,7 @@ describe('Discover grid columns', function () {
showTimeCol: false,
defaultColumns: true,
isSortEnabled: true,
+ isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
@@ -253,6 +255,7 @@ describe('Discover grid columns', function () {
showTimeCol: true,
defaultColumns: false,
isSortEnabled: true,
+ isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
@@ -429,4 +432,190 @@ describe('Discover grid columns', function () {
]
`);
});
+
+ it('returns eui grid with inmemory sorting', async () => {
+ const actual = getEuiGridColumns({
+ columns: ['extension', 'message'],
+ settings: {},
+ dataView: dataViewWithTimefieldMock,
+ showTimeCol: true,
+ defaultColumns: false,
+ isSortEnabled: true,
+ isPlainRecord: true,
+ valueToStringConverter: discoverGridContextMock.valueToStringConverter,
+ rowsCount: 100,
+ services: {
+ uiSettings: discoverServiceMock.uiSettings,
+ toastNotifications: discoverServiceMock.toastNotifications,
+ },
+ hasEditDataViewPermission: () =>
+ discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
+ onFilter: () => {},
+ });
+ expect(actual).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "actions": Object {
+ "additional": Array [
+ Object {
+ "data-test-subj": "gridCopyColumnNameToClipBoardButton",
+ "iconProps": Object {
+ "size": "m",
+ },
+ "iconType": "copyClipboard",
+ "label": ,
+ "onClick": [Function],
+ "size": "xs",
+ },
+ Object {
+ "data-test-subj": "gridCopyColumnValuesToClipBoardButton",
+ "iconProps": Object {
+ "size": "m",
+ },
+ "iconType": "copyClipboard",
+ "label": ,
+ "onClick": [Function],
+ "size": "xs",
+ },
+ ],
+ "showHide": false,
+ "showMoveLeft": true,
+ "showMoveRight": true,
+ },
+ "cellActions": Array [
+ [Function],
+ [Function],
+ [Function],
+ ],
+ "display":
+
+
+ timestamp
+
+
+
+
+
,
+ "displayAsText": "timestamp",
+ "id": "timestamp",
+ "initialWidth": 210,
+ "isSortable": true,
+ "schema": "datetime",
+ },
+ Object {
+ "actions": Object {
+ "additional": Array [
+ Object {
+ "data-test-subj": "gridCopyColumnNameToClipBoardButton",
+ "iconProps": Object {
+ "size": "m",
+ },
+ "iconType": "copyClipboard",
+ "label": ,
+ "onClick": [Function],
+ "size": "xs",
+ },
+ Object {
+ "data-test-subj": "gridCopyColumnValuesToClipBoardButton",
+ "iconProps": Object {
+ "size": "m",
+ },
+ "iconType": "copyClipboard",
+ "label": ,
+ "onClick": [Function],
+ "size": "xs",
+ },
+ ],
+ "showHide": Object {
+ "iconType": "cross",
+ "label": "Remove column",
+ },
+ "showMoveLeft": true,
+ "showMoveRight": true,
+ },
+ "cellActions": Array [
+ [Function],
+ [Function],
+ [Function],
+ ],
+ "displayAsText": "extension",
+ "id": "extension",
+ "isSortable": true,
+ "schema": "string",
+ },
+ Object {
+ "actions": Object {
+ "additional": Array [
+ Object {
+ "data-test-subj": "gridCopyColumnNameToClipBoardButton",
+ "iconProps": Object {
+ "size": "m",
+ },
+ "iconType": "copyClipboard",
+ "label": ,
+ "onClick": [Function],
+ "size": "xs",
+ },
+ Object {
+ "data-test-subj": "gridCopyColumnValuesToClipBoardButton",
+ "iconProps": Object {
+ "size": "m",
+ },
+ "iconType": "copyClipboard",
+ "label": ,
+ "onClick": [Function],
+ "size": "xs",
+ },
+ ],
+ "showHide": Object {
+ "iconType": "cross",
+ "label": "Remove column",
+ },
+ "showMoveLeft": true,
+ "showMoveRight": true,
+ },
+ "cellActions": Array [
+ [Function],
+ ],
+ "displayAsText": "message",
+ "id": "message",
+ "isSortable": true,
+ "schema": "string",
+ },
+ ]
+ `);
+ });
});
diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx
index 6e4c0ec619e2b..b341d6236d235 100644
--- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx
+++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx
@@ -65,6 +65,7 @@ function buildEuiGridColumn({
dataView,
defaultColumns,
isSortEnabled,
+ isPlainRecord,
toastNotifications,
hasEditDataViewPermission,
valueToStringConverter,
@@ -77,6 +78,7 @@ function buildEuiGridColumn({
dataView: DataView;
defaultColumns: boolean;
isSortEnabled: boolean;
+ isPlainRecord?: boolean;
toastNotifications: ToastsStart;
hasEditDataViewPermission: () => boolean;
valueToStringConverter: ValueToStringConverter;
@@ -99,7 +101,7 @@ function buildEuiGridColumn({
const column: EuiDataGridColumn = {
id: columnName,
schema: getSchemaByKbnType(dataViewField?.type),
- isSortable: isSortEnabled && dataViewField?.sortable === true,
+ isSortable: isSortEnabled && (isPlainRecord || dataViewField?.sortable === true),
displayAsText: columnDisplayName,
actions: {
showHide:
@@ -176,6 +178,7 @@ export function getEuiGridColumns({
showTimeCol,
defaultColumns,
isSortEnabled,
+ isPlainRecord,
services,
hasEditDataViewPermission,
valueToStringConverter,
@@ -189,6 +192,7 @@ export function getEuiGridColumns({
showTimeCol: boolean;
defaultColumns: boolean;
isSortEnabled: boolean;
+ isPlainRecord?: boolean;
services: {
uiSettings: IUiSettingsClient;
toastNotifications: ToastsStart;
@@ -213,6 +217,7 @@ export function getEuiGridColumns({
dataView,
defaultColumns,
isSortEnabled,
+ isPlainRecord,
toastNotifications: services.toastNotifications,
hasEditDataViewPermission,
valueToStringConverter,
diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx
index fa0a74f5d0205..eb6788f9bfe7e 100644
--- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx
+++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx
@@ -258,7 +258,7 @@ export class SavedSearchEmbeddable
this.searchProps!.isLoading = false;
this.searchProps!.isPlainRecord = true;
this.searchProps!.showTimeCol = false;
- this.searchProps!.isSortEnabled = false;
+ this.searchProps!.isSortEnabled = true;
return;
}
diff --git a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts
index 3b64ea759316b..7ce405d124533 100644
--- a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts
+++ b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts
@@ -65,7 +65,7 @@ export const setupValueSuggestionProvider = (
) => {
usageCollector?.trackRequest();
return core.http
- .fetch(`/api/kibana/suggestions/values/${index}`, {
+ .fetch(`/internal/kibana/suggestions/values/${index}`, {
method: 'POST',
body: JSON.stringify({
query,
@@ -75,6 +75,7 @@ export const setupValueSuggestionProvider = (
method,
}),
signal,
+ version: '1',
})
.then((r) => {
usageCollector?.trackResult();
diff --git a/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts b/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts
index 74a7e5202a541..00a5d3eba3a13 100644
--- a/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts
+++ b/src/plugins/unified_search/server/autocomplete/value_suggestions_route.ts
@@ -16,55 +16,62 @@ import { termsEnumSuggestions } from './terms_enum';
import { termsAggSuggestions } from './terms_agg';
export function registerValueSuggestionsRoute(router: IRouter, config$: Observable) {
- router.post(
- {
- path: '/api/kibana/suggestions/values/{index}',
- validate: {
- params: schema.object(
- {
- index: schema.string(),
- },
- { unknowns: 'allow' }
- ),
- body: schema.object(
- {
- field: schema.string(),
- query: schema.string(),
- filters: schema.maybe(schema.any()),
- fieldMeta: schema.maybe(schema.any()),
- method: schema.maybe(
- schema.oneOf([schema.literal('terms_agg'), schema.literal('terms_enum')])
+ router.versioned
+ .post({
+ path: '/internal/kibana/suggestions/values/{index}',
+ access: 'internal',
+ })
+ .addVersion(
+ {
+ version: '1',
+ validate: {
+ request: {
+ params: schema.object(
+ {
+ index: schema.string(),
+ },
+ { unknowns: 'allow' }
+ ),
+ body: schema.object(
+ {
+ field: schema.string(),
+ query: schema.string(),
+ filters: schema.maybe(schema.any()),
+ fieldMeta: schema.maybe(schema.any()),
+ method: schema.maybe(
+ schema.oneOf([schema.literal('terms_agg'), schema.literal('terms_enum')])
+ ),
+ },
+ { unknowns: 'allow' }
),
},
- { unknowns: 'allow' }
- ),
+ },
},
- },
- async (context, request, response) => {
- const config = await firstValueFrom(config$);
- const { field: fieldName, query, filters, fieldMeta, method } = request.body;
- const { index } = request.params;
- const abortSignal = getRequestAbortedSignal(request.events.aborted$);
- const { savedObjects, elasticsearch } = await context.core;
+ async (context, request, response) => {
+ const config = await firstValueFrom(config$);
+ const { field: fieldName, query, filters, fieldMeta, method } = request.body;
+ const { index } = request.params;
+ const abortSignal = getRequestAbortedSignal(request.events.aborted$);
+ const { savedObjects, elasticsearch } = await context.core;
- try {
- const fn = method === 'terms_agg' ? termsAggSuggestions : termsEnumSuggestions;
- const body = await fn(
- config,
- savedObjects.client,
- elasticsearch.client.asCurrentUser,
- index,
- fieldName,
- query,
- filters,
- fieldMeta,
- abortSignal
- );
- return response.ok({ body });
- } catch (e) {
- const kbnErr = getKbnServerError(e);
- return reportServerError(response, kbnErr);
+ try {
+ const fn = method === 'terms_agg' ? termsAggSuggestions : termsEnumSuggestions;
+ const body = await fn(
+ config,
+ savedObjects.client,
+ elasticsearch.client.asCurrentUser,
+ index,
+ fieldName,
+ query,
+ filters,
+ fieldMeta,
+ abortSignal
+ );
+ return response.ok({ body });
+ } catch (e) {
+ const kbnErr = getKbnServerError(e);
+ return reportServerError(response, kbnErr);
+ }
}
- }
- );
+ );
}
diff --git a/test/api_integration/apis/suggestions/suggestions.js b/test/api_integration/apis/suggestions/suggestions.js
index ea8da57eda065..928fd995a4b5d 100644
--- a/test/api_integration/apis/suggestions/suggestions.js
+++ b/test/api_integration/apis/suggestions/suggestions.js
@@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
+import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
export default function ({ getService }) {
const esArchiver = getService('esArchiver');
@@ -32,7 +33,8 @@ export default function ({ getService }) {
});
it('should return 200 without a query', () =>
supertest
- .post('/api/kibana/suggestions/values/basic_index')
+ .post('/internal/kibana/suggestions/values/basic_index')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'baz.keyword',
query: '',
@@ -45,7 +47,8 @@ export default function ({ getService }) {
it('should return 200 without a query and with method set to terms_agg', () =>
supertest
- .post('/api/kibana/suggestions/values/basic_index')
+ .post('/internal/kibana/suggestions/values/basic_index')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'baz.keyword',
method: 'terms_agg',
@@ -59,7 +62,8 @@ export default function ({ getService }) {
it('should return 200 without a query and with method set to terms_enum', () =>
supertest
- .post('/api/kibana/suggestions/values/basic_index')
+ .post('/internal/kibana/suggestions/values/basic_index')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'baz.keyword',
method: 'terms_enum',
@@ -73,7 +77,8 @@ export default function ({ getService }) {
it('should return 200 with special characters', () =>
supertest
- .post('/api/kibana/suggestions/values/basic_index')
+ .post('/internal/kibana/suggestions/values/basic_index')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'baz.keyword',
query: '
supertest
- .post('/api/kibana/suggestions/values/basic_index')
+ .post('/internal/kibana/suggestions/values/basic_index')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'nestedField.child',
query: 'nes',
@@ -94,7 +100,8 @@ export default function ({ getService }) {
it('should return 404 if index is not found', () =>
supertest
- .post('/api/kibana/suggestions/values/not_found')
+ .post('/internal/kibana/suggestions/values/not_found')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'baz.keyword',
query: '1',
@@ -103,7 +110,8 @@ export default function ({ getService }) {
it('should return 400 without a query', () =>
supertest
- .post('/api/kibana/suggestions/values/basic_index')
+ .post('/internal/kibana/suggestions/values/basic_index')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'baz.keyword',
})
@@ -111,7 +119,8 @@ export default function ({ getService }) {
it('should return 400 with a bad method', () =>
supertest
- .post('/api/kibana/suggestions/values/basic_index')
+ .post('/internal/kibana/suggestions/values/basic_index')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'baz.keyword',
query: '',
@@ -138,7 +147,8 @@ export default function ({ getService }) {
it('filter is applied on a document level with terms_agg', () =>
supertest
- .post('/api/kibana/suggestions/values/logstash-*')
+ .post('/internal/kibana/suggestions/values/logstash-*')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'extension.raw',
query: '',
@@ -163,7 +173,8 @@ export default function ({ getService }) {
it('filter returns all results because it was applied on an index level with terms_enum', () =>
supertest
- .post('/api/kibana/suggestions/values/logstash-*')
+ .post('/internal/kibana/suggestions/values/logstash-*')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'extension.raw',
query: '',
@@ -188,7 +199,8 @@ export default function ({ getService }) {
it('filter is applied on an index level with terms_enum - find in range', () =>
supertest
- .post('/api/kibana/suggestions/values/logstash-*')
+ .post('/internal/kibana/suggestions/values/logstash-*')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'request.raw',
query: '/uploads/anatoly-art',
@@ -212,7 +224,8 @@ export default function ({ getService }) {
it('filter is applied on an index level with terms_enum - DONT find in range', () => {
supertest
- .post('/api/kibana/suggestions/values/logstash-*')
+ .post('/internal/kibana/suggestions/values/logstash-*')
+ .set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send({
field: 'request.raw',
query: '/uploads/anatoly-art',
diff --git a/test/functional/apps/discover/group2/_sql_view.ts b/test/functional/apps/discover/group2/_sql_view.ts
index e2cfb68cef5b5..6374ee405c70a 100644
--- a/test/functional/apps/discover/group2/_sql_view.ts
+++ b/test/functional/apps/discover/group2/_sql_view.ts
@@ -77,8 +77,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true);
expect(await testSubjects.exists('discoverAlertsButton')).to.be(false);
expect(await testSubjects.exists('shareTopNavButton')).to.be(true);
+ expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(true);
expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(true);
- expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(false);
expect(await testSubjects.exists('fieldListFiltersFieldTypeFilterToggle')).to.be(true);
await testSubjects.click('field-@message-showDetails');
expect(await testSubjects.exists('discoverFieldListPanelEditItem')).to.be(false);
diff --git a/test/tsconfig.json b/test/tsconfig.json
index d3454cc99745a..165093dce8572 100644
--- a/test/tsconfig.json
+++ b/test/tsconfig.json
@@ -67,6 +67,7 @@
"@kbn/dev-proc-runner",
"@kbn/enterprise-search-plugin",
"@kbn/core-saved-objects-server",
- "@kbn/discover-plugin"
+ "@kbn/discover-plugin",
+ "@kbn/core-http-common"
]
}
diff --git a/x-pack/plugins/cases/common/utils/owner.test.ts b/x-pack/plugins/cases/common/utils/owner.test.ts
index 09016880c0a95..94e2c50ea7f08 100644
--- a/x-pack/plugins/cases/common/utils/owner.test.ts
+++ b/x-pack/plugins/cases/common/utils/owner.test.ts
@@ -6,16 +6,30 @@
*/
import { OWNER_INFO } from '../constants';
-import { isValidOwner } from './owner';
+import { getCaseOwnerByAppId, isValidOwner } from './owner';
-describe('isValidOwner', () => {
- const owners = Object.keys(OWNER_INFO) as Array;
+describe('owner utils', () => {
+ describe('isValidOwner', () => {
+ const owners = Object.keys(OWNER_INFO) as Array;
- it.each(owners)('returns true for valid owner: %s', (owner) => {
- expect(isValidOwner(owner)).toBe(true);
+ it.each(owners)('returns true for valid owner: %s', (owner) => {
+ expect(isValidOwner(owner)).toBe(true);
+ });
+
+ it('return false for invalid owner', () => {
+ expect(isValidOwner('not-valid')).toBe(false);
+ });
});
- it('return false for invalid owner', () => {
- expect(isValidOwner('not-valid')).toBe(false);
+ describe('getCaseOwnerByAppId', () => {
+ const tests = Object.values(OWNER_INFO).map((info) => [info.id, info.appId]);
+
+ it.each(tests)('for owner %s it returns %s', (owner, appId) => {
+ expect(getCaseOwnerByAppId(appId)).toBe(owner);
+ });
+
+ it('return undefined for invalid application ID', () => {
+ expect(getCaseOwnerByAppId('not-valid')).toBe(undefined);
+ });
});
});
diff --git a/x-pack/plugins/cases/common/utils/owner.ts b/x-pack/plugins/cases/common/utils/owner.ts
index 44068f36f0d3f..cd817a59a375e 100644
--- a/x-pack/plugins/cases/common/utils/owner.ts
+++ b/x-pack/plugins/cases/common/utils/owner.ts
@@ -9,3 +9,6 @@ import { OWNER_INFO } from '../constants';
export const isValidOwner = (owner: string): owner is keyof typeof OWNER_INFO =>
Object.keys(OWNER_INFO).includes(owner);
+
+export const getCaseOwnerByAppId = (currentAppId?: string) =>
+ Object.values(OWNER_INFO).find((info) => info.appId === currentAppId)?.id;
diff --git a/x-pack/plugins/cases/kibana.jsonc b/x-pack/plugins/cases/kibana.jsonc
index a37320379b76d..6604dc63402ef 100644
--- a/x-pack/plugins/cases/kibana.jsonc
+++ b/x-pack/plugins/cases/kibana.jsonc
@@ -28,7 +28,8 @@
"ruleRegistry",
"files",
"savedObjectsFinder",
- "savedObjectsManagement"
+ "savedObjectsManagement",
+ "uiActions",
],
"optionalPlugins": [
"home",
diff --git a/x-pack/plugins/cases/public/common/lib/kibana/services.ts b/x-pack/plugins/cases/public/common/lib/kibana/services.ts
index ab06c1be0bf02..b1248488e5286 100644
--- a/x-pack/plugins/cases/public/common/lib/kibana/services.ts
+++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts
@@ -8,7 +8,7 @@
import type { CoreStart } from '@kbn/core/public';
import type { CasesUiConfigType } from '../../../../common/ui/types';
-type GlobalServices = Pick;
+type GlobalServices = Pick;
export class KibanaServices {
private static kibanaVersion?: string;
@@ -16,14 +16,16 @@ export class KibanaServices {
private static config?: CasesUiConfigType;
public static init({
+ application,
+ config,
http,
kibanaVersion,
- config,
+ theme,
}: GlobalServices & {
kibanaVersion: string;
config: CasesUiConfigType;
}) {
- this.services = { http };
+ this.services = { application, http, theme };
this.kibanaVersion = kibanaVersion;
this.config = config;
}
diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx
index eecde112f3907..5126aadc32fea 100644
--- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx
@@ -66,7 +66,6 @@ const useKibanaMock = useKibana as jest.MockedFunction;
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
-
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
const mockKibana = () => {
@@ -165,7 +164,6 @@ describe('AllCasesListGeneric', () => {
it('should render AllCasesList', async () => {
useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true });
-
appMockRenderer.render();
await waitFor(() => {
@@ -260,6 +258,21 @@ describe('AllCasesListGeneric', () => {
});
});
+ it('should not call onCreateCasePressed if onRowClick is not provided when create case from case page', async () => {
+ useGetCasesMock.mockReturnValue({
+ ...defaultGetCases,
+ data: {
+ ...defaultGetCases.data,
+ cases: [],
+ },
+ });
+ appMockRenderer.render();
+ userEvent.click(screen.getByTestId('cases-table-add-case'));
+ await waitFor(() => {
+ expect(onRowClick).not.toHaveBeenCalled();
+ });
+ });
+
it('should tableHeaderSortButton AllCasesList', async () => {
appMockRenderer.render();
@@ -347,9 +360,10 @@ describe('AllCasesListGeneric', () => {
it('should call onRowClick with no cases and isSelectorView=true when create case is clicked', async () => {
appMockRenderer.render();
userEvent.click(screen.getByTestId('cases-table-add-case-filter-bar'));
-
+ const isCreateCase = true;
await waitFor(() => {
expect(onRowClick).toHaveBeenCalled();
+ expect(onRowClick).toBeCalledWith(undefined, isCreateCase);
});
});
diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx
index e63315d7e6299..d0fc6f038e1e6 100644
--- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx
@@ -69,7 +69,7 @@ const mapToReadableSolutionName = (solution: string): Solution => {
export interface AllCasesListProps {
hiddenStatuses?: CaseStatusWithAllStatus[];
isSelectorView?: boolean;
- onRowClick?: (theCase?: CaseUI) => void;
+ onRowClick?: (theCase?: CaseUI, isCreateCase?: boolean) => void;
}
export const AllCasesList = React.memo(
@@ -250,6 +250,10 @@ export const AllCasesList = React.memo(
mapToReadableSolutionName(solution)
);
+ const onCreateCasePressed = useCallback(() => {
+ onRowClick?.(undefined, true);
+ }, [onRowClick]);
+
return (
<>
(
severity: filterOptions.severity,
}}
hiddenStatuses={hiddenStatuses}
- onCreateCasePressed={onRowClick}
+ onCreateCasePressed={onCreateCasePressed}
isSelectorView={isSelectorView}
isLoading={isLoadingCurrentUserProfile}
currentUserProfile={currentUserProfile}
@@ -284,7 +288,7 @@ export const AllCasesList = React.memo(
void;
- onClose?: () => void;
+ onClose?: (theCase?: CaseUI, isCreateCase?: boolean) => void;
+ onCreateCaseClicked?: () => void;
}
const Modal = styled(EuiModal)`
@@ -37,20 +38,18 @@ export const AllCasesSelectorModal = React.memo(
({ hiddenStatuses, onRowClick, onClose }) => {
const [isModalOpen, setIsModalOpen] = useState(true);
const closeModal = useCallback(() => {
- if (onClose) {
- onClose();
- }
+ onClose?.();
setIsModalOpen(false);
}, [onClose]);
const onClick = useCallback(
- (theCase?: CaseUI) => {
- closeModal();
- if (onRowClick) {
- onRowClick(theCase);
- }
+ (theCase?: CaseUI, isCreateCase?: boolean) => {
+ onClose?.(theCase, isCreateCase);
+ setIsModalOpen(false);
+
+ onRowClick?.(theCase);
},
- [closeModal, onRowClick]
+ [onClose, onRowClick]
);
return isModalOpen ? (
diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx
index 8e5a27ae06b5e..301baba1d1ccd 100644
--- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx
@@ -106,13 +106,13 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp
}
},
[
- props,
+ appId,
+ casesToasts,
closeModal,
+ createAttachments,
createNewCaseFlyout,
+ props,
startTransaction,
- appId,
- createAttachments,
- casesToasts,
]
);
@@ -130,11 +130,11 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp
onRowClick: (theCase?: CaseUI) => {
handleOnRowClick(theCase, getAttachments);
},
- onClose: () => {
+ onClose: (theCase?: CaseUI, isCreateCase?: boolean) => {
closeModal();
if (props.onClose) {
- return props.onClose();
+ return props.onClose(theCase, isCreateCase);
}
},
},
diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx
index 850c3b3060a9f..791782001a5f9 100644
--- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx
@@ -36,6 +36,7 @@ import { defaultInfiniteUseFindCaseUserActions, defaultUseFindCaseUserActions }
import { ActionTypes } from '../../../../common/api';
import { useGetCaseUserActionsStats } from '../../../containers/use_get_case_user_actions_stats';
import { useInfiniteFindCaseUserActions } from '../../../containers/use_infinite_find_case_user_actions';
+import { useOnUpdateField } from '../use_on_update_field';
jest.mock('../../../containers/use_infinite_find_case_user_actions');
jest.mock('../../../containers/use_find_case_user_actions');
@@ -51,6 +52,7 @@ jest.mock('../../../containers/use_get_tags');
jest.mock('../../../containers/user_profiles/use_bulk_get_user_profiles');
jest.mock('../../../containers/use_get_case_connectors');
jest.mock('../../../containers/use_get_case_users');
+jest.mock('../use_on_update_field');
(useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() });
@@ -118,11 +120,12 @@ const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
const useGetCaseConnectorsMock = useGetCaseConnectors as jest.Mock;
const useGetCaseUsersMock = useGetCaseUsers as jest.Mock;
+const useOnUpdateFieldMock = useOnUpdateField as jest.Mock;
// FLAKY: https://github.com/elastic/kibana/issues/151979
// FLAKY: https://github.com/elastic/kibana/issues/151980
// FLAKY: https://github.com/elastic/kibana/issues/151981
-describe.skip('Case View Page activity tab', () => {
+describe('Case View Page activity tab', () => {
const caseConnectors = getCaseConnectorsMockResponse();
beforeAll(() => {
@@ -138,6 +141,10 @@ describe.skip('Case View Page activity tab', () => {
isLoading: false,
data: caseConnectors,
});
+ useOnUpdateFieldMock.mockReturnValue({
+ isLoading: false,
+ useOnUpdateField: jest.fn,
+ });
});
let appMockRender: AppMockRenderer;
@@ -228,6 +235,30 @@ describe.skip('Case View Page activity tab', () => {
expect(result.queryByTestId('user-actions-list')).not.toBeInTheDocument();
});
+ it('should show a loading when updating severity ', async () => {
+ useOnUpdateFieldMock.mockReturnValue({ isLoading: true, loadingKey: 'severity' });
+
+ const result = appMockRender.render();
+
+ expect(
+ result
+ .getByTestId('case-severity-selection')
+ .classList.contains('euiSuperSelectControl-isLoading')
+ ).toBeTruthy();
+ });
+
+ it('should not show a loading for severity when updating tags', async () => {
+ useOnUpdateFieldMock.mockReturnValue({ isLoading: true, loadingKey: 'tags' });
+
+ const result = appMockRender.render();
+
+ expect(
+ result
+ .getByTestId('case-severity-selection')
+ .classList.contains('euiSuperSelectControl-isLoading')
+ ).not.toBeTruthy();
+ });
+
it('should not render the assignees on basic license', () => {
appMockRender = createAppMockRenderer({ license: basicLicense });
diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx
index 11ef882ec5a42..d4802d4090221 100644
--- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx
@@ -271,7 +271,7 @@ export const CaseViewActivity = ({
) : null}
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx
new file mode 100644
index 0000000000000..6b1dda42d8fe1
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx
@@ -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 React from 'react';
+
+export const ActionWrapper = jest
+ .fn()
+ .mockImplementation(({ children }) => {children}
);
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx
new file mode 100644
index 0000000000000..31f34de24d5ed
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx
@@ -0,0 +1,152 @@
+/*
+ * 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 { render } from '@testing-library/react';
+import React from 'react';
+import { SECURITY_SOLUTION_OWNER } from '../../../../common';
+import { canUseCases } from '../../../client/helpers/can_use_cases';
+import CasesProvider from '../../cases_context';
+import { ActionWrapper } from './action_wrapper';
+import { getMockCaseUiActionProps } from './mocks';
+
+jest.mock('../../cases_context', () =>
+ jest.fn().mockImplementation(({ children, ...props }) => {children}
)
+);
+
+jest.mock('../../../client/helpers/can_use_cases', () => {
+ const actual = jest.requireActual('../../../client/helpers/can_use_cases');
+ return {
+ ...actual,
+ canUseCases: jest.fn(),
+ };
+});
+
+const mockCasePermissions = jest.fn().mockReturnValue({ create: true, update: true });
+
+describe('ActionWrapper', () => {
+ const props = { ...getMockCaseUiActionProps(), currentAppId: 'securitySolutionUI' };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (canUseCases as jest.Mock).mockReturnValue(mockCasePermissions);
+ });
+
+ it('reads cases permissions', () => {
+ render(
+
+
+
+ );
+ expect(mockCasePermissions).toHaveBeenCalledWith([SECURITY_SOLUTION_OWNER]);
+ });
+
+ it('renders CasesProvider with correct props for Security solution', () => {
+ render(
+
+
+
+ );
+ expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
+ Object {
+ "features": Object {
+ "alerts": Object {
+ "sync": true,
+ },
+ },
+ "owner": Array [
+ "securitySolution",
+ ],
+ "permissions": Object {
+ "create": true,
+ "update": true,
+ },
+ }
+ `);
+ });
+
+ it('renders CasesProvider with correct props for stack management', () => {
+ render(
+
+
+
+ );
+
+ expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
+ Object {
+ "features": Object {
+ "alerts": Object {
+ "sync": false,
+ },
+ },
+ "owner": Array [
+ "cases",
+ ],
+ "permissions": Object {
+ "create": true,
+ "update": true,
+ },
+ }
+ `);
+ });
+
+ it('renders CasesProvider with correct props for observability', () => {
+ render(
+
+
+
+ );
+
+ expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
+ Object {
+ "features": Object {
+ "alerts": Object {
+ "sync": false,
+ },
+ },
+ "owner": Array [
+ "observability",
+ ],
+ "permissions": Object {
+ "create": true,
+ "update": true,
+ },
+ }
+ `);
+ });
+
+ it('renders CasesProvider with correct props for an application without cases', () => {
+ render(
+
+
+
+ );
+
+ expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
+ Object {
+ "features": Object {
+ "alerts": Object {
+ "sync": false,
+ },
+ },
+ "owner": Array [],
+ "permissions": Object {
+ "create": true,
+ "update": true,
+ },
+ }
+ `);
+ });
+
+ it('should check permission with undefined if owner is not found', () => {
+ render(
+
+
+
+ );
+ expect(mockCasePermissions).toBeCalledWith(undefined);
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx
new file mode 100644
index 0000000000000..5dd448b9b73e3
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx
@@ -0,0 +1,91 @@
+/*
+ * 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 { PropsWithChildren } from 'react';
+import React from 'react';
+import { Router } from 'react-router-dom';
+import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
+
+import { useIsDarkTheme } from '../../../common/use_is_dark_theme';
+import { SECURITY_SOLUTION_OWNER } from '../../../../common';
+import type { CasesUIActionProps } from './types';
+import { KibanaContextProvider, useKibana } from '../../../common/lib/kibana';
+import CasesProvider from '../../cases_context';
+import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
+import { canUseCases } from '../../../client/helpers/can_use_cases';
+
+export const DEFAULT_DARK_MODE = 'theme:darkMode' as const;
+
+interface Props {
+ caseContextProps: CasesUIActionProps['caseContextProps'];
+ currentAppId?: string;
+}
+
+const ActionWrapperWithContext: React.FC> = ({
+ children,
+ caseContextProps,
+ currentAppId,
+}) => {
+ const { application } = useKibana().services;
+ const isDarkTheme = useIsDarkTheme();
+
+ const owner = getCaseOwnerByAppId(currentAppId);
+ const casePermissions = canUseCases(application.capabilities)(owner ? [owner] : undefined);
+ // TODO: Remove when https://github.com/elastic/kibana/issues/143201 is developed
+ const syncAlerts = owner === SECURITY_SOLUTION_OWNER;
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+ActionWrapperWithContext.displayName = 'ActionWrapperWithContext';
+
+type ActionWrapperComponentProps = PropsWithChildren<
+ CasesUIActionProps & { currentAppId?: string }
+>;
+
+const ActionWrapperComponent: React.FC = ({
+ core,
+ plugins,
+ storage,
+ history,
+ children,
+ caseContextProps,
+ currentAppId,
+}) => {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
+
+ActionWrapperComponent.displayName = 'ActionWrapper';
+
+export const ActionWrapper = React.memo(ActionWrapperComponent);
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx
new file mode 100644
index 0000000000000..08a96ccb8587b
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx
@@ -0,0 +1,217 @@
+/*
+ * 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
+import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
+import type { Action } from '@kbn/ui-actions-plugin/public';
+import ReactDOM, { unmountComponentAtNode } from 'react-dom';
+
+import { createAddToExistingCaseLensAction } from './add_to_existing_case';
+import type { ActionContext, DashboardVisualizationEmbeddable } from './types';
+import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
+import React from 'react';
+import { toMountPoint } from '@kbn/kibana-react-plugin/public';
+import {
+ getMockApplications$,
+ getMockCaseUiActionProps,
+ getMockCurrentAppId$,
+ mockAttributes,
+ MockEmbeddable,
+ mockTimeRange,
+} from './mocks';
+import { CommentType } from '../../../../common';
+import { useKibana } from '../../../common/lib/kibana';
+import { waitFor } from '@testing-library/dom';
+import { canUseCases } from '../../../client/helpers/can_use_cases';
+import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
+
+const element = document.createElement('div');
+document.body.appendChild(element);
+
+jest.mock('../../all_cases/selector_modal/use_cases_add_to_existing_case_modal', () => ({
+ useCasesAddToExistingCaseModal: jest.fn(),
+}));
+
+jest.mock('../../../client/helpers/can_use_cases', () => {
+ const actual = jest.requireActual('../../../client/helpers/can_use_cases');
+ return {
+ ...actual,
+ canUseCases: jest.fn(),
+ };
+});
+
+jest.mock('@kbn/kibana-react-plugin/public', () => ({
+ toMountPoint: jest.fn(),
+ KibanaThemeProvider: jest.fn().mockImplementation(({ children }) => <>{children}>),
+}));
+
+jest.mock('../../../common/lib/kibana', () => {
+ return {
+ useKibana: jest.fn(),
+ KibanaContextProvider: jest
+ .fn()
+ .mockImplementation(({ children, ...props }) => {children}
),
+ };
+});
+
+jest.mock('react-dom', () => {
+ const original = jest.requireActual('react-dom');
+ return { ...original, unmountComponentAtNode: jest.fn() };
+});
+
+jest.mock('./action_wrapper');
+
+jest.mock('../../../../common/utils/owner', () => ({
+ getCaseOwnerByAppId: jest.fn().mockReturnValue('securitySolution'),
+}));
+
+describe('createAddToExistingCaseLensAction', () => {
+ const mockEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE, {
+ id: 'mockId',
+ attributes: mockAttributes,
+ timeRange: mockTimeRange,
+ }) as unknown as DashboardVisualizationEmbeddable;
+
+ const context = {
+ embeddable: mockEmbeddable,
+ } as unknown as ActionContext;
+
+ const caseUiActionProps = getMockCaseUiActionProps();
+
+ const mockUseCasesAddToExistingCaseModal = useCasesAddToExistingCaseModal as jest.Mock;
+ const mockOpenModal = jest.fn();
+ const mockMount = jest.fn();
+ let action: Action;
+ const mockCasePermissions = jest.fn();
+ beforeEach(() => {
+ mockUseCasesAddToExistingCaseModal.mockReturnValue({
+ open: mockOpenModal,
+ });
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ currentAppId$: getMockCurrentAppId$(),
+ applications$: getMockApplications$(),
+ },
+ },
+ });
+ (canUseCases as jest.Mock).mockReturnValue(
+ mockCasePermissions.mockReturnValue({ create: true, update: true })
+ );
+ (toMountPoint as jest.Mock).mockImplementation((node) => {
+ ReactDOM.render(node, element);
+ return mockMount;
+ });
+ jest.clearAllMocks();
+ action = createAddToExistingCaseLensAction(caseUiActionProps);
+ });
+
+ test('it should return display name', () => {
+ expect(action.getDisplayName(context)).toEqual('Add to existing case');
+ });
+
+ it('should return icon type', () => {
+ expect(action.getIconType(context)).toEqual('casesApp');
+ });
+
+ describe('isCompatible', () => {
+ it('should return false if error embeddable', async () => {
+ expect(
+ await action.isCompatible({
+ ...context,
+ embeddable: new ErrorEmbeddable('some error', {
+ id: '123',
+ }) as unknown as DashboardVisualizationEmbeddable,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if not lens embeddable', async () => {
+ expect(
+ await action.isCompatible({
+ ...context,
+ embeddable: new MockEmbeddable('not_lens') as unknown as DashboardVisualizationEmbeddable,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if no permission', async () => {
+ mockCasePermissions.mockReturnValue({ create: false, update: false });
+ expect(await action.isCompatible(context)).toEqual(false);
+ });
+
+ it('should return true if is lens embeddable', async () => {
+ expect(await action.isCompatible(context)).toEqual(true);
+ });
+
+ it('should check permission with undefined if owner is not found', async () => {
+ (getCaseOwnerByAppId as jest.Mock).mockReturnValue(undefined);
+ await action.isCompatible(context);
+ expect(mockCasePermissions).toBeCalledWith(undefined);
+ });
+ });
+
+ describe('execute', () => {
+ beforeEach(async () => {
+ await action.execute(context);
+ });
+
+ it('should execute', () => {
+ expect(toMountPoint).toHaveBeenCalled();
+ expect(mockMount).toHaveBeenCalled();
+ });
+ });
+
+ describe('Add to existing case modal', () => {
+ beforeEach(async () => {
+ await action.execute(context);
+ });
+
+ it('should open modal with an attachment', async () => {
+ await waitFor(() => {
+ expect(mockOpenModal).toHaveBeenCalled();
+
+ const getAttachments = mockOpenModal.mock.calls[0][0].getAttachments;
+ expect(getAttachments()).toEqual(
+ expect.objectContaining([
+ {
+ comment: `!{lens${JSON.stringify({
+ timeRange: mockTimeRange,
+ attributes: mockAttributes,
+ })}}`,
+ type: CommentType.user as const,
+ },
+ ])
+ );
+ });
+ });
+
+ it('should have correct onClose handler - when close modal clicked', () => {
+ const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
+ onClose();
+ expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
+ });
+
+ it('should have correct onClose handler - when case selected', () => {
+ const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
+ onClose({ id: 'case-id', title: 'case-title' });
+ expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
+ });
+
+ it('should have correct onClose handler - when case created', () => {
+ const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
+ onClose(null, true);
+ expect(unmountComponentAtNode as jest.Mock).not.toHaveBeenCalled();
+ });
+
+ it('should have correct onSuccess handler', () => {
+ const onSuccess = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onSuccess;
+ onSuccess();
+ expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx
new file mode 100644
index 0000000000000..ef5a7c794f1f5
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx
@@ -0,0 +1,132 @@
+/*
+ * 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, { useEffect, useMemo } from 'react';
+import { unmountComponentAtNode } from 'react-dom';
+
+import { createAction } from '@kbn/ui-actions-plugin/public';
+import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
+
+import { toMountPoint } from '@kbn/kibana-react-plugin/public';
+
+import type { CaseUI } from '../../../../common';
+import { isLensEmbeddable, hasInput, getLensCaseAttachment } from './utils';
+
+import type { ActionContext, CasesUIActionProps, DashboardVisualizationEmbeddable } from './types';
+import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
+import { ADD_TO_EXISTING_CASE_DISPLAYNAME } from './translations';
+import { ActionWrapper } from './action_wrapper';
+import { canUseCases } from '../../../client/helpers/can_use_cases';
+import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
+
+export const ACTION_ID = 'embeddable_addToExistingCase';
+export const DEFAULT_DARK_MODE = 'theme:darkMode' as const;
+
+interface Props {
+ embeddable: DashboardVisualizationEmbeddable;
+ onSuccess: () => void;
+ onClose: (theCase?: CaseUI) => void;
+}
+
+const AddExistingCaseModalWrapper: React.FC = ({ embeddable, onClose, onSuccess }) => {
+ const modal = useCasesAddToExistingCaseModal({
+ onClose,
+ onSuccess,
+ });
+
+ const attachments = useMemo(() => {
+ const { attributes, timeRange } = embeddable.getInput();
+
+ return [getLensCaseAttachment({ attributes, timeRange })];
+ }, [embeddable]);
+ useEffect(() => {
+ modal.open({ getAttachments: () => attachments });
+ }, [attachments, modal]);
+
+ return null;
+};
+
+AddExistingCaseModalWrapper.displayName = 'AddExistingCaseModalWrapper';
+
+export const createAddToExistingCaseLensAction = ({
+ core,
+ plugins,
+ storage,
+ history,
+ caseContextProps,
+}: CasesUIActionProps) => {
+ const { application: applicationService, theme } = core;
+
+ let currentAppId: string | undefined;
+
+ applicationService?.currentAppId$.subscribe((appId) => {
+ currentAppId = appId;
+ });
+
+ return createAction({
+ id: ACTION_ID,
+ type: 'actionButton',
+ getIconType: () => 'casesApp',
+ getDisplayName: () => ADD_TO_EXISTING_CASE_DISPLAYNAME,
+ isCompatible: async ({ embeddable }) => {
+ const owner = getCaseOwnerByAppId(currentAppId);
+ const casePermissions = canUseCases(applicationService.capabilities)(
+ owner ? [owner] : undefined
+ );
+
+ return (
+ !isErrorEmbeddable(embeddable) &&
+ isLensEmbeddable(embeddable) &&
+ casePermissions.update &&
+ casePermissions.create &&
+ hasInput(embeddable)
+ );
+ },
+ execute: async ({ embeddable }) => {
+ const targetDomElement = document.createElement('div');
+
+ const cleanupDom = (shouldCleanup?: boolean) => {
+ if (targetDomElement != null && shouldCleanup) {
+ unmountComponentAtNode(targetDomElement);
+ }
+ };
+
+ const onClose = (theCase?: CaseUI, isCreateCase?: boolean) => {
+ const closeModalClickedScenario = theCase == null && !isCreateCase;
+ const caseSelectedScenario = theCase != null;
+ // When `Creating` a case from the `add to existing case modal`,
+ // we close the modal and then open the flyout.
+ // If we clean up dom when closing the modal, then the flyout won't open.
+ // Thus we do not clean up dom when `Creating` a case.
+ const shouldCleanup = closeModalClickedScenario || caseSelectedScenario;
+ cleanupDom(shouldCleanup);
+ };
+
+ const onSuccess = () => {
+ cleanupDom(true);
+ };
+ const mount = toMountPoint(
+
+
+ ,
+ { theme$: theme.theme$ }
+ );
+
+ mount(targetDomElement);
+ },
+ });
+};
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx
new file mode 100644
index 0000000000000..7e99bbaae24c6
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx
@@ -0,0 +1,208 @@
+/*
+ * 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
+import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
+import type { Action } from '@kbn/ui-actions-plugin/public';
+
+import { createAddToNewCaseLensAction } from './add_to_new_case';
+import type { ActionContext, DashboardVisualizationEmbeddable } from './types';
+import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout';
+import React from 'react';
+import { toMountPoint } from '@kbn/kibana-react-plugin/public';
+import {
+ getMockApplications$,
+ getMockCaseUiActionProps,
+ getMockCurrentAppId$,
+ mockAttributes,
+ MockEmbeddable,
+ mockTimeRange,
+} from './mocks';
+import ReactDOM, { unmountComponentAtNode } from 'react-dom';
+import { useKibana } from '../../../common/lib/kibana';
+import { CommentType } from '../../../../common';
+import { waitFor } from '@testing-library/dom';
+import { canUseCases } from '../../../client/helpers/can_use_cases';
+import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
+
+const element = document.createElement('div');
+document.body.appendChild(element);
+
+jest.mock('@kbn/kibana-react-plugin/public', () => ({
+ toMountPoint: jest.fn(),
+}));
+
+jest.mock('../../../common/lib/kibana', () => {
+ return {
+ useKibana: jest.fn(),
+ KibanaContextProvider: jest
+ .fn()
+ .mockImplementation(({ children, ...props }) => {children}
),
+ };
+});
+
+jest.mock('../../create/flyout/use_cases_add_to_new_case_flyout', () => ({
+ useCasesAddToNewCaseFlyout: jest.fn(),
+}));
+
+jest.mock('../../../client/helpers/can_use_cases', () => {
+ const actual = jest.requireActual('../../../client/helpers/can_use_cases');
+ return {
+ ...actual,
+ canUseCases: jest.fn(),
+ };
+});
+
+jest.mock('react-dom', () => {
+ const original = jest.requireActual('react-dom');
+ return { ...original, unmountComponentAtNode: jest.fn() };
+});
+
+jest.mock('./action_wrapper');
+
+jest.mock('../../../../common/utils/owner', () => ({
+ getCaseOwnerByAppId: jest.fn().mockReturnValue('securitySolution'),
+}));
+
+describe('createAddToNewCaseLensAction', () => {
+ const mockEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE, {
+ id: 'mockId',
+ attributes: mockAttributes,
+ timeRange: mockTimeRange,
+ }) as unknown as DashboardVisualizationEmbeddable;
+
+ const context = {
+ embeddable: mockEmbeddable,
+ } as unknown as ActionContext;
+
+ const caseUiActionProps = getMockCaseUiActionProps();
+
+ const mockUseCasesAddToNewCaseFlyout = useCasesAddToNewCaseFlyout as jest.Mock;
+ const mockOpenFlyout = jest.fn();
+ const mockMount = jest.fn();
+ let action: Action;
+ const mockCasePermissions = jest.fn();
+
+ beforeEach(() => {
+ mockUseCasesAddToNewCaseFlyout.mockReturnValue({
+ open: mockOpenFlyout,
+ });
+
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ currentAppId$: getMockCurrentAppId$(),
+ applications$: getMockApplications$(),
+ },
+ },
+ });
+
+ (canUseCases as jest.Mock).mockReturnValue(
+ mockCasePermissions.mockReturnValue({ create: true, update: true })
+ );
+
+ (toMountPoint as jest.Mock).mockImplementation((node) => {
+ ReactDOM.render(node, element);
+ return mockMount;
+ });
+
+ jest.clearAllMocks();
+ action = createAddToNewCaseLensAction(caseUiActionProps);
+ });
+
+ test('it should return display name', () => {
+ expect(action.getDisplayName(context)).toEqual('Add to new case');
+ });
+
+ it('should return icon type', () => {
+ expect(action.getIconType(context)).toEqual('casesApp');
+ });
+
+ describe('isCompatible', () => {
+ it('should return false if error embeddable', async () => {
+ expect(
+ await action.isCompatible({
+ ...context,
+ embeddable: new ErrorEmbeddable('some error', {
+ id: '123',
+ }) as unknown as DashboardVisualizationEmbeddable,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if not lens embeddable', async () => {
+ expect(
+ await action.isCompatible({
+ ...context,
+ embeddable: new MockEmbeddable('not_lens') as unknown as DashboardVisualizationEmbeddable,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if no permission', async () => {
+ mockCasePermissions.mockReturnValue({ create: false, update: false });
+ expect(await action.isCompatible(context)).toEqual(false);
+ });
+
+ it('should return true if is lens embeddable', async () => {
+ expect(await action.isCompatible(context)).toEqual(true);
+ });
+
+ it('should check permission with undefined if owner is not found', async () => {
+ (getCaseOwnerByAppId as jest.Mock).mockReturnValue(undefined);
+ await action.isCompatible(context);
+ expect(mockCasePermissions).toBeCalledWith(undefined);
+ });
+ });
+
+ describe('execute', () => {
+ beforeEach(async () => {
+ await action.execute(context);
+ });
+
+ it('should execute', () => {
+ expect(toMountPoint).toHaveBeenCalled();
+ expect(mockMount).toHaveBeenCalled();
+ });
+ });
+
+ describe('Add to new case flyout', () => {
+ beforeEach(async () => {
+ await action.execute(context);
+ });
+
+ it('should open flyout', async () => {
+ await waitFor(() => {
+ expect(mockOpenFlyout).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attachments: [
+ {
+ comment: `!{lens${JSON.stringify({
+ timeRange: mockTimeRange,
+ attributes: mockAttributes,
+ })}}`,
+ type: CommentType.user as const,
+ },
+ ],
+ })
+ );
+ });
+ });
+
+ it('should have correct onClose handler', () => {
+ const onClose = mockUseCasesAddToNewCaseFlyout.mock.calls[0][0].onClose;
+ onClose();
+ expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
+ });
+
+ it('should have correct onSuccess handler', () => {
+ const onSuccess = mockUseCasesAddToNewCaseFlyout.mock.calls[0][0].onSuccess;
+ onSuccess();
+ expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx
new file mode 100644
index 0000000000000..f09ccaa3a4baf
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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, { useEffect, useMemo } from 'react';
+import { unmountComponentAtNode } from 'react-dom';
+
+import { createAction } from '@kbn/ui-actions-plugin/public';
+import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
+
+import { toMountPoint } from '@kbn/kibana-react-plugin/public';
+import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
+import { hasInput, isLensEmbeddable, getLensCaseAttachment } from './utils';
+
+import type { ActionContext, CasesUIActionProps, DashboardVisualizationEmbeddable } from './types';
+import { ADD_TO_CASE_SUCCESS, ADD_TO_NEW_CASE_DISPLAYNAME } from './translations';
+import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout';
+import { ActionWrapper } from './action_wrapper';
+import { canUseCases } from '../../../client/helpers/can_use_cases';
+
+export const ACTION_ID = 'embeddable_addToNewCase';
+export const DEFAULT_DARK_MODE = 'theme:darkMode' as const;
+
+interface Props {
+ embeddable: DashboardVisualizationEmbeddable;
+ onSuccess: () => void;
+ onClose: () => void;
+}
+
+const AddToNewCaseFlyoutWrapper: React.FC = ({ embeddable, onClose, onSuccess }) => {
+ const { attributes, timeRange } = embeddable.getInput();
+ const createNewCaseFlyout = useCasesAddToNewCaseFlyout({
+ onClose,
+ onSuccess,
+ toastContent: ADD_TO_CASE_SUCCESS,
+ });
+
+ const attachments = useMemo(
+ () => [getLensCaseAttachment({ attributes, timeRange })],
+ [attributes, timeRange]
+ );
+
+ useEffect(() => {
+ createNewCaseFlyout.open({ attachments });
+ }, [attachments, createNewCaseFlyout]);
+
+ return null;
+};
+
+AddToNewCaseFlyoutWrapper.displayName = 'AddToNewCaseFlyoutWrapper';
+
+export const createAddToNewCaseLensAction = ({
+ core,
+ plugins,
+ storage,
+ history,
+ caseContextProps,
+}: CasesUIActionProps) => {
+ const { application: applicationService, theme } = core;
+
+ let currentAppId: string | undefined;
+
+ applicationService?.currentAppId$.subscribe((appId) => {
+ currentAppId = appId;
+ });
+
+ return createAction({
+ id: ACTION_ID,
+ type: 'actionButton',
+ getIconType: () => 'casesApp',
+ getDisplayName: () => ADD_TO_NEW_CASE_DISPLAYNAME,
+ isCompatible: async ({ embeddable }) => {
+ const owner = getCaseOwnerByAppId(currentAppId);
+ const casePermissions = canUseCases(applicationService.capabilities)(
+ owner ? [owner] : undefined
+ );
+
+ return (
+ !isErrorEmbeddable(embeddable) &&
+ isLensEmbeddable(embeddable) &&
+ casePermissions.update &&
+ casePermissions.create &&
+ hasInput(embeddable)
+ );
+ },
+ execute: async ({ embeddable }) => {
+ const targetDomElement = document.createElement('div');
+
+ const cleanupDom = () => {
+ if (targetDomElement != null) {
+ unmountComponentAtNode(targetDomElement);
+ }
+ };
+
+ const onFlyoutClose = () => {
+ cleanupDom();
+ };
+
+ const mount = toMountPoint(
+
+
+ ,
+ { theme$: theme.theme$ }
+ );
+
+ mount(targetDomElement);
+ },
+ });
+};
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/index.ts b/x-pack/plugins/cases/public/components/visualizations/actions/index.ts
new file mode 100644
index 0000000000000..e96d41d4466e5
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { registerUIActions as registerActions } from './register';
diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts b/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts
new file mode 100644
index 0000000000000..808935fddd2e8
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts
@@ -0,0 +1,96 @@
+/*
+ * 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 { CoreTheme, PublicAppInfo } from '@kbn/core/public';
+import { BehaviorSubject, of } from 'rxjs';
+import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
+import { createBrowserHistory } from 'history';
+import type { CasesUIActionProps } from './types';
+
+const mockTheme: CoreTheme = {
+ darkMode: false,
+};
+
+const createThemeMock = (): CoreTheme => {
+ return { ...mockTheme };
+};
+
+export const createTheme$Mock = () => {
+ return of(createThemeMock());
+};
+
+export class MockEmbeddable {
+ public type;
+ private input;
+ constructor(
+ type: string,
+ input?: {
+ attributes: TypedLensByValueInput['attributes'];
+ id: string;
+ timeRange: { from: string; to: string; fromStr: string; toStr: string };
+ }
+ ) {
+ this.type = type;
+ this.input = input;
+ }
+ getFilters() {}
+ getQuery() {}
+ getInput() {
+ return this.input;
+ }
+}
+
+export const mockAttributes = {
+ title: 'mockTitle',
+ description: 'mockDescription',
+ references: [],
+ state: {
+ visualization: {
+ id: 'mockId',
+ type: 'mockType',
+ title: 'mockTitle',
+ visualizationType: 'mockVisualizationType',
+ references: [],
+ state: {
+ datasourceStates: {
+ indexpattern: {},
+ },
+ },
+ },
+ },
+} as unknown as TypedLensByValueInput['attributes'];
+
+export const mockTimeRange = { from: '', to: '', fromStr: '', toStr: '' };
+
+export const getMockCurrentAppId$ = () => new BehaviorSubject('securitySolutionUI');
+export const getMockApplications$ = () =>
+ new BehaviorSubject