{roleMappings.length === 0 ? emptyPrompt : roleMappingsTable}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts
index 3c9cf01bbb4a9..f45491334567f 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__';
+import { mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__';
import { LogicMounter } from '../../../__mocks__/kea.mock';
import { groups } from '../../__mocks__/groups.mock';
@@ -13,20 +13,20 @@ import { groups } from '../../__mocks__/groups.mock';
import { nextTick } from '@kbn/test/jest';
import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles';
-import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants';
+import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants';
import { RoleMappingsLogic } from './role_mappings_logic';
describe('RoleMappingsLogic', () => {
const { http } = mockHttpValues;
- const { navigateToUrl } = mockKibanaValues;
- const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers;
+ const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers;
const { mount } = new LogicMounter(RoleMappingsLogic);
const defaultValues = {
attributes: [],
availableAuthProviders: [],
elasticsearchRoles: [],
roleMapping: null,
+ roleMappingFlyoutOpen: false,
roleMappings: [],
roleType: 'admin',
attributeValue: '',
@@ -37,6 +37,7 @@ describe('RoleMappingsLogic', () => {
selectedGroups: new Set(),
includeInAllGroups: false,
selectedAuthProviders: [ANY_AUTH_PROVIDER],
+ selectedOptions: [],
};
const roleGroup = {
id: '123',
@@ -92,6 +93,7 @@ describe('RoleMappingsLogic', () => {
expect(RoleMappingsLogic.values.selectedGroups).toEqual(
new Set([wsRoleMapping.groups[0].id])
);
+ expect(RoleMappingsLogic.values.selectedOptions).toEqual([]);
});
it('sets default group with new role mapping', () => {
@@ -121,10 +123,13 @@ describe('RoleMappingsLogic', () => {
},
});
- RoleMappingsLogic.actions.handleGroupSelectionChange(otherGroup.id, true);
+ RoleMappingsLogic.actions.handleGroupSelectionChange([group.id, otherGroup.id]);
expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([group.id, otherGroup.id]));
+ expect(RoleMappingsLogic.values.selectedOptions).toEqual([
+ { label: roleGroup.name, value: roleGroup.id },
+ ]);
- RoleMappingsLogic.actions.handleGroupSelectionChange(otherGroup.id, false);
+ RoleMappingsLogic.actions.handleGroupSelectionChange([group.id]);
expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([group.id]));
});
@@ -223,6 +228,25 @@ describe('RoleMappingsLogic', () => {
expect(RoleMappingsLogic.values.attributeName).toEqual('username');
expect(clearFlashMessages).toHaveBeenCalled();
});
+
+ it('openRoleMappingFlyout', () => {
+ mount(mappingServerProps);
+ RoleMappingsLogic.actions.openRoleMappingFlyout();
+
+ expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(true);
+ expect(clearFlashMessages).toHaveBeenCalled();
+ });
+
+ it('closeRoleMappingFlyout', () => {
+ mount({
+ ...mappingServerProps,
+ roleMappingFlyoutOpen: true,
+ });
+ RoleMappingsLogic.actions.closeRoleMappingFlyout();
+
+ expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false);
+ expect(clearFlashMessages).toHaveBeenCalled();
+ });
});
describe('listeners', () => {
@@ -275,17 +299,21 @@ describe('RoleMappingsLogic', () => {
expect(flashAPIErrors).toHaveBeenCalledWith('this is an error');
});
- it('redirects when there is a 404 status', async () => {
+ it('shows error when there is a 404 status', async () => {
http.get.mockReturnValue(Promise.reject({ status: 404 }));
RoleMappingsLogic.actions.initializeRoleMapping();
await nextTick();
- expect(navigateToUrl).toHaveBeenCalled();
+ expect(setErrorMessage).toHaveBeenCalledWith(ROLE_MAPPING_NOT_FOUND);
});
});
describe('handleSaveMapping', () => {
- it('calls API and navigates when new mapping', async () => {
+ it('calls API and refreshes list when new mapping', async () => {
+ const initializeRoleMappingsSpy = jest.spyOn(
+ RoleMappingsLogic.actions,
+ 'initializeRoleMappings'
+ );
RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps);
http.post.mockReturnValue(Promise.resolve(mappingServerProps));
@@ -304,10 +332,14 @@ describe('RoleMappingsLogic', () => {
});
await nextTick();
- expect(navigateToUrl).toHaveBeenCalled();
+ expect(initializeRoleMappingsSpy).toHaveBeenCalled();
});
- it('calls API and navigates when existing mapping', async () => {
+ it('calls API and refreshes list when existing mapping', async () => {
+ const initializeRoleMappingsSpy = jest.spyOn(
+ RoleMappingsLogic.actions,
+ 'initializeRoleMappings'
+ );
RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps);
http.put.mockReturnValue(Promise.resolve(mappingServerProps));
@@ -329,7 +361,7 @@ describe('RoleMappingsLogic', () => {
);
await nextTick();
- expect(navigateToUrl).toHaveBeenCalled();
+ expect(initializeRoleMappingsSpy).toHaveBeenCalled();
});
it('handles error', async () => {
@@ -343,6 +375,7 @@ describe('RoleMappingsLogic', () => {
describe('handleDeleteMapping', () => {
let confirmSpy: any;
+ const roleMappingId = 'r1';
beforeEach(() => {
confirmSpy = jest.spyOn(window, 'confirm');
@@ -353,29 +386,27 @@ describe('RoleMappingsLogic', () => {
confirmSpy.mockRestore();
});
- it('returns when no mapping', () => {
- RoleMappingsLogic.actions.handleDeleteMapping();
-
- expect(http.delete).not.toHaveBeenCalled();
- });
-
- it('calls API and navigates', async () => {
+ it('calls API and refreshes list', async () => {
+ const initializeRoleMappingsSpy = jest.spyOn(
+ RoleMappingsLogic.actions,
+ 'initializeRoleMappings'
+ );
RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps);
http.delete.mockReturnValue(Promise.resolve({}));
- RoleMappingsLogic.actions.handleDeleteMapping();
+ RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId);
expect(http.delete).toHaveBeenCalledWith(
- `/api/workplace_search/org/role_mappings/${wsRoleMapping.id}`
+ `/api/workplace_search/org/role_mappings/${roleMappingId}`
);
await nextTick();
- expect(navigateToUrl).toHaveBeenCalled();
+ expect(initializeRoleMappingsSpy).toHaveBeenCalled();
});
it('handles error', async () => {
RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps);
http.delete.mockReturnValue(Promise.reject('this is an error'));
- RoleMappingsLogic.actions.handleDeleteMapping();
+ RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId);
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('this is an error');
@@ -384,7 +415,7 @@ describe('RoleMappingsLogic', () => {
it('will do nothing if not confirmed', async () => {
RoleMappingsLogic.actions.setRoleMappingData(mappingServerProps);
window.confirm = () => false;
- RoleMappingsLogic.actions.handleDeleteMapping();
+ RoleMappingsLogic.actions.handleDeleteMapping(roleMappingId);
expect(http.delete).not.toHaveBeenCalled();
await nextTick();
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts
index 0df7f0ba37569..85f3bb49ee508 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts
@@ -7,16 +7,17 @@
import { kea, MakeLogicType } from 'kea';
+import { EuiComboBoxOptionOption } from '@elastic/eui';
+
import {
clearFlashMessages,
flashAPIErrors,
setSuccessMessage,
+ setErrorMessage,
} from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
-import { KibanaLogic } from '../../../shared/kibana';
-import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants';
+import { ANY_AUTH_PROVIDER, ROLE_MAPPING_NOT_FOUND } from '../../../shared/role_mapping/constants';
import { AttributeName } from '../../../shared/types';
-import { ROLE_MAPPINGS_PATH } from '../../routes';
import { RoleGroup, WSRoleMapping, Role } from '../../types';
import {
@@ -54,18 +55,17 @@ interface RoleMappingsActions {
firstElasticsearchRole: string
): { value: AttributeName; firstElasticsearchRole: string };
handleAttributeValueChange(value: string): { value: string };
- handleDeleteMapping(): void;
- handleGroupSelectionChange(
- groupId: string,
- selected: boolean
- ): { groupId: string; selected: boolean };
+ handleDeleteMapping(roleMappingId: string): { roleMappingId: string };
+ handleGroupSelectionChange(groupIds: string[]): { groupIds: string[] };
handleRoleChange(roleType: Role): { roleType: Role };
handleSaveMapping(): void;
- initializeRoleMapping(roleId?: string): { roleId?: string };
+ initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string };
initializeRoleMappings(): void;
resetState(): void;
setRoleMappingData(data: RoleMappingServerDetails): RoleMappingServerDetails;
setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails;
+ openRoleMappingFlyout(): void;
+ closeRoleMappingFlyout(): void;
}
interface RoleMappingsValues {
@@ -83,6 +83,8 @@ interface RoleMappingsValues {
roleType: Role;
selectedAuthProviders: string[];
selectedGroups: Set
;
+ roleMappingFlyoutOpen: boolean;
+ selectedOptions: EuiComboBoxOptionOption[];
}
export const RoleMappingsLogic = kea>({
@@ -92,7 +94,7 @@ export const RoleMappingsLogic = kea data,
handleAuthProviderChange: (value: string[]) => ({ value }),
handleRoleChange: (roleType: Role) => ({ roleType }),
- handleGroupSelectionChange: (groupId: string, selected: boolean) => ({ groupId, selected }),
+ handleGroupSelectionChange: (groupIds: string[]) => ({ groupIds }),
handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({
value,
firstElasticsearchRole,
@@ -101,9 +103,11 @@ export const RoleMappingsLogic = kea ({ selected }),
resetState: true,
initializeRoleMappings: true,
- initializeRoleMapping: (roleId?: string) => ({ roleId }),
- handleDeleteMapping: true,
+ initializeRoleMapping: (roleMappingId?: string) => ({ roleMappingId }),
+ handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }),
handleSaveMapping: true,
+ openRoleMappingFlyout: true,
+ closeRoleMappingFlyout: false,
},
reducers: {
dataLoading: [
@@ -152,6 +156,7 @@ export const RoleMappingsLogic = kea roleMapping || null,
resetState: () => null,
+ closeRoleMappingFlyout: () => null,
},
],
roleType: [
@@ -178,6 +183,7 @@ export const RoleMappingsLogic = kea value,
resetState: () => '',
+ closeRoleMappingFlyout: () => '',
},
],
attributeName: [
@@ -187,6 +193,7 @@ export const RoleMappingsLogic = kea value,
resetState: () => 'username',
+ closeRoleMappingFlyout: () => 'username',
},
],
selectedGroups: [
@@ -200,13 +207,10 @@ export const RoleMappingsLogic = kea group.name === DEFAULT_GROUP_NAME)
.map((group) => group.id)
),
- handleGroupSelectionChange: (groups, { groupId, selected }) => {
- const newSelectedGroupNames = new Set(groups as Set);
- if (selected) {
- newSelectedGroupNames.add(groupId);
- } else {
- newSelectedGroupNames.delete(groupId);
- }
+ handleGroupSelectionChange: (_, { groupIds }) => {
+ const newSelectedGroupNames = new Set() as Set;
+ groupIds.forEach((groupId) => newSelectedGroupNames.add(groupId));
+
return newSelectedGroupNames;
},
},
@@ -234,7 +238,27 @@ export const RoleMappingsLogic = kea true,
+ closeRoleMappingFlyout: () => false,
+ initializeRoleMappings: () => false,
+ initializeRoleMapping: () => true,
+ },
+ ],
},
+ selectors: ({ selectors }) => ({
+ selectedOptions: [
+ () => [selectors.selectedGroups, selectors.availableGroups],
+ (selectedGroups, availableGroups) => {
+ const selectedIds = Array.from(selectedGroups.values());
+ return availableGroups
+ .filter(({ id }: { id: string }) => selectedIds.includes(id))
+ .map(({ id, name }: { id: string; name: string }) => ({ label: name, value: id }));
+ },
+ ],
+ }),
listeners: ({ actions, values }) => ({
initializeRoleMappings: async () => {
const { http } = HttpLogic.values;
@@ -247,11 +271,10 @@ export const RoleMappingsLogic = kea {
+ initializeRoleMapping: async ({ roleMappingId }) => {
const { http } = HttpLogic.values;
- const { navigateToUrl } = KibanaLogic.values;
- const route = roleId
- ? `/api/workplace_search/org/role_mappings/${roleId}`
+ const route = roleMappingId
+ ? `/api/workplace_search/org/role_mappings/${roleMappingId}`
: '/api/workplace_search/org/role_mappings/new';
try {
@@ -259,23 +282,20 @@ export const RoleMappingsLogic = kea {
- const { roleMapping } = values;
- if (!roleMapping) return;
-
+ handleDeleteMapping: async ({ roleMappingId }) => {
const { http } = HttpLogic.values;
- const { navigateToUrl } = KibanaLogic.values;
- const route = `/api/workplace_search/org/role_mappings/${roleMapping.id}`;
+ const route = `/api/workplace_search/org/role_mappings/${roleMappingId}`;
if (window.confirm(DELETE_ROLE_MAPPING_MESSAGE)) {
try {
await http.delete(route);
- navigateToUrl(ROLE_MAPPINGS_PATH);
+ actions.initializeRoleMappings();
setSuccessMessage(ROLE_MAPPING_DELETED_MESSAGE);
} catch (e) {
flashAPIErrors(e);
@@ -284,7 +304,6 @@ export const RoleMappingsLogic = kea {
const { http } = HttpLogic.values;
- const { navigateToUrl } = KibanaLogic.values;
const {
attributeName,
attributeValue,
@@ -315,7 +334,7 @@ export const RoleMappingsLogic = kea {
clearFlashMessages();
},
+ closeRoleMappingFlyout: () => {
+ clearFlashMessages();
+ },
+ openRoleMappingFlyout: () => {
+ clearFlashMessages();
+ },
}),
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.test.tsx
deleted file mode 100644
index e9fc40ba1dbb4..0000000000000
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.test.tsx
+++ /dev/null
@@ -1,26 +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 { Route, Switch } from 'react-router-dom';
-
-import { shallow } from 'enzyme';
-
-import { RoleMapping } from './role_mapping';
-import { RoleMappings } from './role_mappings';
-import { RoleMappingsRouter } from './role_mappings_router';
-
-describe('RoleMappingsRouter', () => {
- it('renders', () => {
- const wrapper = shallow();
-
- expect(wrapper.find(Switch)).toHaveLength(1);
- expect(wrapper.find(Route)).toHaveLength(3);
- expect(wrapper.find(RoleMapping)).toHaveLength(2);
- expect(wrapper.find(RoleMappings)).toHaveLength(1);
- });
-});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.tsx
deleted file mode 100644
index fa5ab12c8afc0..0000000000000
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_router.tsx
+++ /dev/null
@@ -1,34 +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 { Route, Switch } from 'react-router-dom';
-
-import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
-import { NAV } from '../../constants';
-import { ROLE_MAPPING_NEW_PATH, ROLE_MAPPING_PATH, ROLE_MAPPINGS_PATH } from '../../routes';
-
-import { RoleMapping } from './role_mapping';
-import { RoleMappings } from './role_mappings';
-
-export const RoleMappingsRouter: React.FC = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- >
-);
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts
new file mode 100644
index 0000000000000..626a107b6942b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { mockDependencies, mockRequestHandler, MockRouter } from '../../__mocks__';
+
+import { registerCrawlerRoutes } from './crawler';
+
+describe('crawler routes', () => {
+ describe('GET /api/app_search/engines/{name}/crawler', () => {
+ let mockRouter: MockRouter;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouter = new MockRouter({
+ method: 'get',
+ path: '/api/app_search/engines/{name}/crawler',
+ });
+
+ registerCrawlerRoutes({
+ ...mockDependencies,
+ router: mockRouter.router,
+ });
+ });
+
+ it('creates a request to enterprise search', () => {
+ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
+ path: '/api/as/v0/engines/:name/crawler',
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts
new file mode 100644
index 0000000000000..15b8340b07d4e
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 { schema } from '@kbn/config-schema';
+
+import { RouteDependencies } from '../../plugin';
+
+export function registerCrawlerRoutes({
+ router,
+ enterpriseSearchRequestHandler,
+}: RouteDependencies) {
+ router.get(
+ {
+ path: '/api/app_search/engines/{name}/crawler',
+ validate: {
+ params: schema.object({
+ name: schema.string(),
+ }),
+ },
+ },
+ enterpriseSearchRequestHandler.createRequest({
+ path: '/api/as/v0/engines/:name/crawler',
+ })
+ );
+}
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts
index 6ccdce0935d93..2442b61c632c1 100644
--- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts
@@ -9,6 +9,7 @@ import { RouteDependencies } from '../../plugin';
import { registerAnalyticsRoutes } from './analytics';
import { registerApiLogsRoutes } from './api_logs';
+import { registerCrawlerRoutes } from './crawler';
import { registerCredentialsRoutes } from './credentials';
import { registerCurationsRoutes } from './curations';
import { registerDocumentsRoutes, registerDocumentRoutes } from './documents';
@@ -42,4 +43,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => {
registerResultSettingsRoutes(dependencies);
registerApiLogsRoutes(dependencies);
registerOnboardingRoutes(dependencies);
+ registerCrawlerRoutes(dependencies);
};
diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json
index f2515d0a6a8fb..da04db1086aa8 100644
--- a/x-pack/plugins/event_log/generated/mappings.json
+++ b/x-pack/plugins/event_log/generated/mappings.json
@@ -275,6 +275,10 @@
"type": {
"type": "keyword",
"ignore_above": 1024
+ },
+ "type_id": {
+ "type": "keyword",
+ "ignore_above": 1024
}
}
}
diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts
index 31d8b7201cfc6..a13b304e8adab 100644
--- a/x-pack/plugins/event_log/generated/schemas.ts
+++ b/x-pack/plugins/event_log/generated/schemas.ts
@@ -116,6 +116,7 @@ export const EventSchema = schema.maybe(
namespace: ecsString(),
id: ecsString(),
type: ecsString(),
+ type_id: ecsString(),
})
)
),
diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js
index a7e5f4ae6cb1e..f2020e76b46ba 100644
--- a/x-pack/plugins/event_log/scripts/mappings.js
+++ b/x-pack/plugins/event_log/scripts/mappings.js
@@ -60,6 +60,10 @@ exports.EcsCustomPropertyMappings = {
type: 'keyword',
ignore_above: 1024,
},
+ type_id: {
+ type: 'keyword',
+ ignore_above: 1024,
+ },
},
},
},
diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts
index 601d54ec56c46..7b3ddaada8001 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts
+++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/enrollment_api_keys.ts
@@ -11,6 +11,8 @@ import type {
GetOneEnrollmentAPIKeyResponse,
GetEnrollmentAPIKeysResponse,
GetEnrollmentAPIKeysRequest,
+ PostEnrollmentAPIKeyRequest,
+ PostEnrollmentAPIKeyResponse,
} from '../../types';
import { useRequest, sendRequest, useConditionalRequest } from './use_request';
@@ -65,3 +67,11 @@ export function useGetEnrollmentAPIKeys(
...options,
});
}
+
+export function sendCreateEnrollmentAPIKey(body: PostEnrollmentAPIKeyRequest['body']) {
+ return sendRequest({
+ method: 'post',
+ path: enrollmentAPIKeyRouteService.getCreatePath(),
+ body,
+ });
+}
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx
index bcedb23b32d5d..4edc1121b1091 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/agent_policy_selection.tsx
@@ -8,21 +8,25 @@
import React, { useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-import { EuiSelect, EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui';
+import { EuiButtonEmpty, EuiButton, EuiCallOut, EuiSelect, EuiSpacer, EuiText } from '@elastic/eui';
import { SO_SEARCH_LIMIT } from '../../../../constants';
import type { AgentPolicy, GetEnrollmentAPIKeysResponse } from '../../../../types';
-import { sendGetEnrollmentAPIKeys, useStartServices } from '../../../../hooks';
+import {
+ sendGetEnrollmentAPIKeys,
+ useStartServices,
+ sendCreateEnrollmentAPIKey,
+} from '../../../../hooks';
import { AgentPolicyPackageBadges } from '../agent_policy_package_badges';
type Props = {
agentPolicies?: AgentPolicy[];
- onAgentPolicyChange?: (key: string) => void;
+ onAgentPolicyChange?: (key?: string) => void;
excludeFleetServer?: boolean;
} & (
| {
withKeySelection: true;
- onKeyChange?: (key: string) => void;
+ onKeyChange?: (key?: string) => void;
}
| {
withKeySelection: false;
@@ -38,6 +42,8 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => {
const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState(
[]
);
+ const [isLoadingEnrollmentKey, setIsLoadingEnrollmentKey] = useState(false);
+
const [selectedState, setSelectedState] = useState<{
agentPolicyId?: string;
enrollmentAPIKeyId?: string;
@@ -45,7 +51,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => {
useEffect(
function triggerOnAgentPolicyChangeEffect() {
- if (onAgentPolicyChange && selectedState.agentPolicyId) {
+ if (onAgentPolicyChange) {
onAgentPolicyChange(selectedState.agentPolicyId);
}
},
@@ -58,7 +64,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => {
return;
}
- if (selectedState.enrollmentAPIKeyId) {
+ if (onKeyChange) {
onKeyChange(selectedState.enrollmentAPIKeyId);
}
},
@@ -94,6 +100,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => {
return;
}
if (!selectedState.agentPolicyId) {
+ setIsAuthenticationSettingsOpen(true);
setEnrollmentAPIKeys([]);
return;
}
@@ -204,28 +211,89 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => {
{isAuthenticationSettingsOpen && (
<>
- ({
- value: key.id,
- text: key.name,
- }))}
- value={selectedState.enrollmentAPIKeyId || undefined}
- prepend={
-
+ {enrollmentAPIKeys.length && selectedState.enrollmentAPIKeyId ? (
+ ({
+ value: key.id,
+ text: key.name,
+ }))}
+ value={selectedState.enrollmentAPIKeyId || undefined}
+ prepend={
+
+
+
+ }
+ onChange={(e) => {
+ setSelectedState({
+ ...selectedState,
+ enrollmentAPIKeyId: e.target.value,
+ });
+ }}
+ />
+ ) : (
+
+
+
+
+
+ {
+ setIsLoadingEnrollmentKey(true);
+ if (selectedState.agentPolicyId) {
+ sendCreateEnrollmentAPIKey({ policy_id: selectedState.agentPolicyId })
+ .then((res) => {
+ if (res.error) {
+ throw res.error;
+ }
+ setIsLoadingEnrollmentKey(false);
+ if (res.data?.item) {
+ setEnrollmentAPIKeys([res.data.item]);
+ setSelectedState({
+ ...selectedState,
+ enrollmentAPIKeyId: res.data.item.id,
+ });
+ notifications.toasts.addSuccess(
+ i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', {
+ defaultMessage: 'Enrollment token created',
+ })
+ );
+ }
+ })
+ .catch((error) => {
+ setIsLoadingEnrollmentKey(false);
+ notifications.toasts.addError(error, {
+ title: 'Error',
+ });
+ });
+ }
+ }}
+ >
-
- }
- onChange={(e) => {
- setSelectedState({
- ...selectedState,
- enrollmentAPIKeyId: e.target.value,
- });
- }}
- />
+
+
+ )}
>
)}
>
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx
index 0158af2d78470..df1630abfab47 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx
@@ -18,6 +18,8 @@ import {
useLink,
useFleetStatus,
} from '../../../../hooks';
+import { NewEnrollmentTokenModal } from '../../enrollment_token_list_page/components/new_enrollment_key_modal';
+
import { ManualInstructions } from '../../../../components/enrollment_instructions';
import {
FleetServerRequirementPage,
@@ -99,7 +101,7 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => {
title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', {
defaultMessage: 'Enroll and start the Elastic Agent',
}),
- children: apiKey.data && (
+ children: selectedAPIKeyId && apiKey.data && (
),
});
@@ -107,12 +109,18 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => {
return baseSteps;
}, [
agentPolicies,
+ selectedAPIKeyId,
apiKey.data,
isFleetServerPolicySelected,
settings.data?.item?.fleet_server_hosts,
fleetServerInstructions,
]);
+ const [isModalOpen, setModalOpen] = useState(false);
+ const closeModal = () => {
+ setModalOpen(false);
+ };
+
return (
<>
{fleetStatus.isReady ? (
@@ -125,6 +133,10 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => {
+
+ {isModalOpen && (
+
+ )}
>
) : fleetStatus.missingRequirements?.length === 1 &&
fleetStatus.missingRequirements[0] === 'fleet_server' ? (
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx
index 6a446e888a19f..8ba0098b3d277 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/steps.tsx
@@ -54,8 +54,8 @@ export const AgentPolicySelectionStep = ({
excludeFleetServer,
}: {
agentPolicies?: AgentPolicy[];
- setSelectedAPIKeyId?: (key: string) => void;
- setSelectedPolicyId?: (policyId: string) => void;
+ setSelectedAPIKeyId?: (key?: string) => void;
+ setSelectedPolicyId?: (policyId?: string) => void;
setIsFleetServerPolicySelected?: (selected: boolean) => void;
excludeFleetServer?: boolean;
}) => {
@@ -67,11 +67,11 @@ export const AgentPolicySelectionStep = ({
: [];
const onAgentPolicyChange = useCallback(
- async (policyId: string) => {
+ async (policyId?: string) => {
if (setSelectedPolicyId) {
setSelectedPolicyId(policyId);
}
- if (setIsFleetServerPolicySelected) {
+ if (policyId && setIsFleetServerPolicySelected) {
const agentPolicyRequest = await sendGetOneAgentPolicy(policyId);
if (
agentPolicyRequest.data?.item &&
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx
similarity index 50%
rename from x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx
rename to x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx
index 7fae295d0d5b4..29e130f5583ab 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_flyout.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/new_enrollment_key_modal.tsx
@@ -7,32 +7,16 @@
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
-import {
- EuiFlyout,
- EuiFlyoutBody,
- EuiFlyoutHeader,
- EuiTitle,
- EuiFlexGroup,
- EuiFlexItem,
- EuiButtonEmpty,
- EuiButton,
- EuiFlyoutFooter,
- EuiForm,
- EuiFormRow,
- EuiFieldText,
- EuiSelect,
-} from '@elastic/eui';
-import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiConfirmModal, EuiForm, EuiFormRow, EuiFieldText, EuiSelect } from '@elastic/eui';
-import type { AgentPolicy } from '../../../../types';
-import { useInput, useStartServices, sendRequest } from '../../../../hooks';
-import { enrollmentAPIKeyRouteService } from '../../../../services';
+import type { AgentPolicy, EnrollmentAPIKey } from '../../../../types';
+import { useInput, useStartServices, sendCreateEnrollmentAPIKey } from '../../../../hooks';
function useCreateApiKeyForm(
policyIdDefaultValue: string | undefined,
- onSuccess: (keyId: string) => void
+ onSuccess: (key: EnrollmentAPIKey) => void,
+ onError: (error: Error) => void
) {
- const { notifications } = useStartServices();
const [isLoading, setIsLoading] = useState(false);
const apiKeyNameInput = useInput('');
const policyIdInput = useInput(policyIdDefaultValue);
@@ -41,31 +25,23 @@ function useCreateApiKeyForm(
event.preventDefault();
setIsLoading(true);
try {
- const res = await sendRequest({
- method: 'post',
- path: enrollmentAPIKeyRouteService.getCreatePath(),
- body: JSON.stringify({
- name: apiKeyNameInput.value,
- policy_id: policyIdInput.value,
- }),
+ const res = await sendCreateEnrollmentAPIKey({
+ name: apiKeyNameInput.value,
+ policy_id: policyIdInput.value,
});
+
if (res.error) {
throw res.error;
}
policyIdInput.clear();
apiKeyNameInput.clear();
setIsLoading(false);
- onSuccess(res.data.item.id);
- notifications.toasts.addSuccess(
- i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', {
- defaultMessage: 'Enrollment token created.',
- })
- );
- } catch (err) {
- notifications.toasts.addError(err as Error, {
- title: 'Error',
- });
+ if (res.data?.item) {
+ onSuccess(res.data.item);
+ }
+ } catch (error) {
setIsLoading(false);
+ onError(error);
}
};
@@ -78,18 +54,32 @@ function useCreateApiKeyForm(
}
interface Props {
- onClose: () => void;
- agentPolicies: AgentPolicy[];
+ onClose: (key?: EnrollmentAPIKey) => void;
+ agentPolicies?: AgentPolicy[];
}
-export const NewEnrollmentTokenFlyout: React.FunctionComponent = ({
+export const NewEnrollmentTokenModal: React.FunctionComponent = ({
onClose,
agentPolicies = [],
}) => {
+ const { notifications } = useStartServices();
const policyIdDefaultValue = agentPolicies.find((agentPolicy) => agentPolicy.is_default)?.id;
- const form = useCreateApiKeyForm(policyIdDefaultValue, () => {
- onClose();
- });
+ const form = useCreateApiKeyForm(
+ policyIdDefaultValue,
+ (key: EnrollmentAPIKey) => {
+ onClose(key);
+ notifications.toasts.addSuccess(
+ i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', {
+ defaultMessage: 'Enrollment token created',
+ })
+ );
+ },
+ (error: Error) => {
+ notifications.toasts.addError(error, {
+ title: 'Error',
+ });
+ }
+ );
const body = (
@@ -124,41 +114,26 @@ export const NewEnrollmentTokenFlyout: React.FunctionComponent = ({
}))}
/>
-
-
-
);
return (
-
-
-
-
-
-
-
-
- {body}
-
-
-
-
-
-
-
-
-
-
+ onClose()}
+ cancelButtonText={i18n.translate('xpack.fleet.newEnrollmentKey.cancelButtonLabel', {
+ defaultMessage: 'Cancel',
+ })}
+ onConfirm={form.onSubmit}
+ confirmButtonText={i18n.translate('xpack.fleet.newEnrollmentKey.submitButton', {
+ defaultMessage: 'Create enrollment token',
+ })}
+ >
+ {body}
+
);
};
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx
index 6d141b0c9ebf1..66e0c338dbbbc 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx
@@ -34,7 +34,7 @@ import {
import type { EnrollmentAPIKey, GetAgentPoliciesResponseItem } from '../../../types';
import { SearchBar } from '../../../components/search_bar';
-import { NewEnrollmentTokenFlyout } from './components/new_enrollment_key_flyout';
+import { NewEnrollmentTokenModal } from './components/new_enrollment_key_modal';
import { ConfirmEnrollmentTokenDelete } from './components/confirm_delete_modal';
const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId }) => {
@@ -156,7 +156,7 @@ const DeleteButton: React.FunctionComponent<{ apiKey: EnrollmentAPIKey; refresh:
export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => {
useBreadcrumbs('fleet_enrollment_tokens');
- const [flyoutOpen, setFlyoutOpen] = useState(false);
+ const [isModalOpen, setModalOpen] = useState(false);
const [search, setSearch] = useState('');
const { pagination, setPagination, pageSizeOptions } = usePagination();
@@ -270,11 +270,11 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => {
return (
<>
- {flyoutOpen && (
- {
- setFlyoutOpen(false);
+ onClose={(key?: EnrollmentAPIKey) => {
+ setModalOpen(false);
enrollmentAPIKeysRequest.resendRequest();
}}
/>
@@ -301,7 +301,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => {
/>
- setFlyoutOpen(true)}>
+ setModalOpen(true)}>
{
return defer(() => {
@@ -68,10 +108,16 @@ export const dataMock = {
};
-export const fetch = function (url, params) {
+export const fetch = async function (url, params) {
switch (url) {
case '/api/infra/log_source_configurations/default':
return DEFAULT_SOURCE_CONFIGURATION;
+ case '/api/infra/log_source_configurations/default/status':
+ return {
+ data: {
+ logIndexStatus: 'available',
+ }
+ };
default:
return {};
}
diff --git a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx
index dbf032415cb99..9d3a611cff88d 100644
--- a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx
+++ b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx
@@ -21,7 +21,7 @@ import { Pick2 } from '../../common/utility_types';
type MockIndexPattern = Pick<
IndexPattern,
- 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName'
+ 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName' | 'getComputedFields'
>;
export type MockIndexPatternSpec = Pick<
IIndexPattern,
@@ -35,23 +35,7 @@ export const MockIndexPatternsKibanaContextProvider: React.FC<{
mockIndexPatterns: MockIndexPatternSpec[];
}> = ({ asyncDelay, children, mockIndexPatterns }) => {
const indexPatterns = useMemo(
- () =>
- createIndexPatternsMock(
- asyncDelay,
- mockIndexPatterns.map(({ id, title, type = undefined, fields, timeFieldName }) => {
- const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec));
-
- return {
- id,
- title,
- type,
- getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName),
- isTimeBased: () => timeFieldName != null,
- getFieldByName: (fieldName) =>
- indexPatternFields.find(({ name }) => name === fieldName),
- };
- })
- ),
+ () => createIndexPatternsMock(asyncDelay, mockIndexPatterns.map(createIndexPatternMock)),
[asyncDelay, mockIndexPatterns]
);
@@ -71,7 +55,7 @@ export const MockIndexPatternsKibanaContextProvider: React.FC<{
);
};
-const createIndexPatternsMock = (
+export const createIndexPatternsMock = (
asyncDelay: number,
indexPatterns: MockIndexPattern[]
): {
@@ -93,3 +77,36 @@ const createIndexPatternsMock = (
},
};
};
+
+export const createIndexPatternMock = ({
+ id,
+ title,
+ type = undefined,
+ fields,
+ timeFieldName,
+}: MockIndexPatternSpec): MockIndexPattern => {
+ const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec));
+
+ return {
+ id,
+ title,
+ type,
+ getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName),
+ isTimeBased: () => timeFieldName != null,
+ getFieldByName: (fieldName) => indexPatternFields.find(({ name }) => name === fieldName),
+ getComputedFields: () => ({
+ docvalueFields: [],
+ runtimeFields: indexPatternFields.reduce((accumulatedRuntimeFields, field) => {
+ if (field.runtimeField != null) {
+ return {
+ ...accumulatedRuntimeFields,
+ [field.name]: field.runtimeField,
+ };
+ }
+ return accumulatedRuntimeFields;
+ }, {}),
+ scriptFields: {},
+ storedFields: [],
+ }),
+ };
+};
diff --git a/x-pack/plugins/lens/public/assets/chart_heatmap.tsx b/x-pack/plugins/lens/public/assets/chart_heatmap.tsx
new file mode 100644
index 0000000000000..7da242f82eb60
--- /dev/null
+++ b/x-pack/plugins/lens/public/assets/chart_heatmap.tsx
@@ -0,0 +1,31 @@
+/*
+ * 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 { EuiIconProps } from '@elastic/eui';
+import React from 'react';
+
+export const LensIconChartHeatmap = ({ title, titleId, ...props }: Omit) => (
+
+);
diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts
index fb3ba62405904..b0ecc412c357f 100644
--- a/x-pack/plugins/lens/public/async_services.ts
+++ b/x-pack/plugins/lens/public/async_services.ts
@@ -18,6 +18,7 @@ export * from './datatable_visualization/datatable_visualization';
export * from './metric_visualization/metric_visualization';
export * from './pie_visualization/pie_visualization';
export * from './xy_visualization/xy_visualization';
+export * from './heatmap_visualization/heatmap_visualization';
export * from './indexpattern_datasource/indexpattern';
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss
index 9f4b60b6d3c67..3fafa8b37a42f 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss
@@ -11,6 +11,10 @@
color: $euiTextSubduedColor;
}
+.lnsChartSwitch__append {
+ display: inline-flex;
+}
+
// Targeting img as this won't target normal EuiIcon's only the custom svgs's
img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying-type
// The large icons aren't square so max out the width to fill the height
@@ -19,4 +23,4 @@ img.lnsChartSwitch__chartIcon { // stylelint-disable-line selector-no-qualifying
.lnsChartSwitch__search {
width: 7 * $euiSizeXXL;
-}
\ No newline at end of file
+}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
index 5538dd26d0323..ba0e09bdd894c 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
@@ -16,6 +16,7 @@ import {
EuiSelectable,
EuiIconTip,
EuiSelectableOption,
+ EuiBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
@@ -286,6 +287,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
.map(
(v): SelectableEntry => ({
'aria-label': v.fullLabel || v.label,
+ className: 'lnsChartSwitch__option',
isGroupLabel: false,
key: `${v.visualizationId}:${v.id}`,
value: `${v.visualizationId}:${v.id}`,
@@ -295,22 +297,45 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
),
append:
- v.selection.dataLoss !== 'nothing' ? (
-
+ v.selection.dataLoss !== 'nothing' || v.showBetaBadge ? (
+
+ {v.selection.dataLoss !== 'nothing' ? (
+
+
+
+ ) : null}
+ {v.showBetaBadge ? (
+
+
+
+
+
+ ) : null}
+
) : null,
// Apparently checked: null is not valid for TS
...(subVisualizationId === v.id && { checked: 'on' }),
@@ -363,6 +388,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
= ({
+ data,
+ args,
+ timeZone,
+ formatFactory,
+ chartsThemeService,
+ onClickValue,
+ onSelectRange,
+}) => {
+ const chartTheme = chartsThemeService.useChartsTheme();
+ const isDarkTheme = chartsThemeService.useDarkMode();
+
+ const tableId = Object.keys(data.tables)[0];
+ const table = data.tables[tableId];
+
+ const xAxisColumnIndex = table.columns.findIndex((v) => v.id === args.xAccessor);
+ const yAxisColumnIndex = table.columns.findIndex((v) => v.id === args.yAccessor);
+
+ const xAxisColumn = table.columns[xAxisColumnIndex];
+ const yAxisColumn = table.columns[yAxisColumnIndex];
+ const valueColumn = table.columns.find((v) => v.id === args.valueAccessor);
+
+ if (!xAxisColumn || !valueColumn) {
+ // Chart is not ready
+ return null;
+ }
+
+ let chartData = table.rows.filter((v) => typeof v[args.valueAccessor!] === 'number');
+
+ if (!yAxisColumn) {
+ // required for tooltip
+ chartData = chartData.map((row) => {
+ return {
+ ...row,
+ unifiedY: '',
+ };
+ });
+ }
+
+ const xAxisMeta = xAxisColumn.meta;
+ const isTimeBasedSwimLane = xAxisMeta.type === 'date';
+
+ // Fallback to the ordinal scale type when a single row of data is provided.
+ // Related issue https://github.com/elastic/elastic-charts/issues/1184
+ const xScaleType =
+ isTimeBasedSwimLane && chartData.length > 1 ? ScaleType.Time : ScaleType.Ordinal;
+
+ const xValuesFormatter = formatFactory(xAxisMeta.params);
+ const valueFormatter = formatFactory(valueColumn.meta.params);
+
+ const onElementClick = ((e: HeatmapElementEvent[]) => {
+ const cell = e[0][0];
+ const { x, y } = cell.datum;
+
+ const xAxisFieldName = xAxisColumn.meta.field;
+ const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : '';
+
+ const points = [
+ {
+ row: table.rows.findIndex((r) => r[xAxisColumn.id] === x),
+ column: xAxisColumnIndex,
+ value: x,
+ },
+ ...(yAxisColumn
+ ? [
+ {
+ row: table.rows.findIndex((r) => r[yAxisColumn.id] === y),
+ column: yAxisColumnIndex,
+ value: y,
+ },
+ ]
+ : []),
+ ];
+
+ const context: LensFilterEvent['data'] = {
+ data: points.map((point) => ({
+ row: point.row,
+ column: point.column,
+ value: point.value,
+ table,
+ })),
+ timeFieldName,
+ };
+ onClickValue(desanitizeFilterContext(context));
+ }) as ElementClickListener;
+
+ const onBrushEnd = (e: HeatmapBrushEvent) => {
+ const { x, y } = e;
+
+ const xAxisFieldName = xAxisColumn.meta.field;
+ const timeFieldName = isTimeBasedSwimLane ? xAxisFieldName : '';
+
+ if (isTimeBasedSwimLane) {
+ const context: LensBrushEvent['data'] = {
+ range: x as number[],
+ table,
+ column: xAxisColumnIndex,
+ timeFieldName,
+ };
+ onSelectRange(context);
+ } else {
+ const points: Array<{ row: number; column: number; value: string | number }> = [];
+
+ if (yAxisColumn) {
+ (y as string[]).forEach((v) => {
+ points.push({
+ row: table.rows.findIndex((r) => r[yAxisColumn.id] === v),
+ column: yAxisColumnIndex,
+ value: v,
+ });
+ });
+ }
+
+ (x as string[]).forEach((v) => {
+ points.push({
+ row: table.rows.findIndex((r) => r[xAxisColumn.id] === v),
+ column: xAxisColumnIndex,
+ value: v,
+ });
+ });
+
+ const context: LensFilterEvent['data'] = {
+ data: points.map((point) => ({
+ row: point.row,
+ column: point.column,
+ value: point.value,
+ table,
+ })),
+ timeFieldName,
+ };
+ onClickValue(desanitizeFilterContext(context));
+ }
+ };
+
+ const config: HeatmapSpec['config'] = {
+ onBrushEnd,
+ grid: {
+ stroke: {
+ width:
+ args.gridConfig.strokeWidth ?? chartTheme.axes?.gridLine?.horizontal?.strokeWidth ?? 1,
+ color:
+ args.gridConfig.strokeColor ?? chartTheme.axes?.gridLine?.horizontal?.stroke ?? '#D3DAE6',
+ },
+ cellHeight: {
+ max: 'fill',
+ min: 1,
+ },
+ },
+ cell: {
+ maxWidth: 'fill',
+ maxHeight: 'fill',
+ label: {
+ visible: args.gridConfig.isCellLabelVisible ?? false,
+ },
+ border: {
+ strokeWidth: 0,
+ },
+ },
+ yAxisLabel: {
+ visible: !!yAxisColumn && args.gridConfig.isYAxisLabelVisible,
+ // eui color subdued
+ fill: chartTheme.axes?.tickLabel?.fill ?? '#6a717d',
+ padding: yAxisColumn?.name ? 8 : 0,
+ name: yAxisColumn?.name ?? '',
+ ...(yAxisColumn
+ ? {
+ formatter: (v: number | string) => formatFactory(yAxisColumn.meta.params).convert(v),
+ }
+ : {}),
+ },
+ xAxisLabel: {
+ visible: args.gridConfig.isXAxisLabelVisible,
+ // eui color subdued
+ fill: chartTheme.axes?.tickLabel?.fill ?? `#6a717d`,
+ formatter: (v: number | string) => xValuesFormatter.convert(v),
+ name: xAxisColumn.name,
+ },
+ brushMask: {
+ fill: isDarkTheme ? 'rgb(30,31,35,80%)' : 'rgb(247,247,247,50%)',
+ },
+ brushArea: {
+ stroke: isDarkTheme ? 'rgb(255, 255, 255)' : 'rgb(105, 112, 125)',
+ },
+ timeZone,
+ };
+
+ if (!chartData || !chartData.length) {
+ return ;
+ }
+
+ const colorPalette = euiPaletteForTemperature(5);
+
+ return (
+
+
+ valueFormatter.convert(v)}
+ xScaleType={xScaleType}
+ ySortPredicate="dataIndex"
+ config={config}
+ xSortPredicate="dataIndex"
+ />
+
+ );
+};
+
+const MemoizedChart = React.memo(HeatmapComponent);
+
+export function HeatmapChartReportable(props: HeatmapRenderProps) {
+ const [state, setState] = useState({
+ isReady: false,
+ });
+
+ // It takes a cycle for the XY chart to render. This prevents
+ // reporting from printing a blank chart placeholder.
+ useEffect(() => {
+ setState({ isReady: true });
+ }, [setState]);
+
+ return (
+
+
+
+ );
+}
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/constants.ts b/x-pack/plugins/lens/public/heatmap_visualization/constants.ts
new file mode 100644
index 0000000000000..ee1be917f5bfd
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/constants.ts
@@ -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 { i18n } from '@kbn/i18n';
+import { LensIconChartHeatmap } from '../assets/chart_heatmap';
+
+export const LENS_HEATMAP_RENDERER = 'lens_heatmap_renderer';
+
+export const LENS_HEATMAP_ID = 'lnsHeatmap';
+
+const groupLabel = i18n.translate('xpack.lens.heatmap.groupLabel', {
+ defaultMessage: 'Heatmap',
+});
+
+export const CHART_SHAPES = {
+ HEATMAP: 'heatmap',
+} as const;
+
+export const CHART_NAMES = {
+ heatmap: {
+ shapeType: CHART_SHAPES.HEATMAP,
+ icon: LensIconChartHeatmap,
+ label: i18n.translate('xpack.lens.heatmap.heatmapLabel', {
+ defaultMessage: 'Heatmap',
+ }),
+ groupLabel,
+ },
+};
+
+export const GROUP_ID = {
+ X: 'x',
+ Y: 'y',
+ CELL: 'cell',
+} as const;
+
+export const FUNCTION_NAME = 'lens_heatmap';
+
+export const LEGEND_FUNCTION = 'lens_heatmap_legendConfig';
+
+export const HEATMAP_GRID_FUNCTION = 'lens_heatmap_grid';
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx b/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx
new file mode 100644
index 0000000000000..f0521dadf88bf
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/expression.tsx
@@ -0,0 +1,275 @@
+/*
+ * 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';
+import { I18nProvider } from '@kbn/i18n/react';
+import ReactDOM from 'react-dom';
+import React from 'react';
+import { Position } from '@elastic/charts';
+import {
+ ExpressionFunctionDefinition,
+ IInterpreterRenderHandlers,
+} from '../../../../../src/plugins/expressions';
+import { FormatFactory, LensBrushEvent, LensFilterEvent, LensMultiTable } from '../types';
+import {
+ FUNCTION_NAME,
+ HEATMAP_GRID_FUNCTION,
+ LEGEND_FUNCTION,
+ LENS_HEATMAP_RENDERER,
+} from './constants';
+import type {
+ HeatmapExpressionArgs,
+ HeatmapExpressionProps,
+ HeatmapGridConfig,
+ HeatmapGridConfigResult,
+ HeatmapRender,
+ LegendConfigResult,
+} from './types';
+import { HeatmapLegendConfig } from './types';
+import { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public';
+import { HeatmapChartReportable } from './chart_component';
+
+export const heatmapGridConfig: ExpressionFunctionDefinition<
+ typeof HEATMAP_GRID_FUNCTION,
+ null,
+ HeatmapGridConfig,
+ HeatmapGridConfigResult
+> = {
+ name: HEATMAP_GRID_FUNCTION,
+ aliases: [],
+ type: HEATMAP_GRID_FUNCTION,
+ help: `Configure the heatmap layout `,
+ inputTypes: ['null'],
+ args: {
+ // grid
+ strokeWidth: {
+ types: ['number'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.strokeWidth.help', {
+ defaultMessage: 'Specifies the grid stroke width',
+ }),
+ required: false,
+ },
+ strokeColor: {
+ types: ['string'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.strokeColor.help', {
+ defaultMessage: 'Specifies the grid stroke color',
+ }),
+ required: false,
+ },
+ cellHeight: {
+ types: ['number'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.cellHeight.help', {
+ defaultMessage: 'Specifies the grid cell height',
+ }),
+ required: false,
+ },
+ cellWidth: {
+ types: ['number'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.cellWidth.help', {
+ defaultMessage: 'Specifies the grid cell width',
+ }),
+ required: false,
+ },
+ // cells
+ isCellLabelVisible: {
+ types: ['boolean'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.isCellLabelVisible.help', {
+ defaultMessage: 'Specifies whether or not the cell label is visible.',
+ }),
+ },
+ // Y-axis
+ isYAxisLabelVisible: {
+ types: ['boolean'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.isYAxisLabelVisible.help', {
+ defaultMessage: 'Specifies whether or not the Y-axis labels are visible.',
+ }),
+ },
+ yAxisLabelWidth: {
+ types: ['number'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.yAxisLabelWidth.help', {
+ defaultMessage: 'Specifies the width of the Y-axis labels.',
+ }),
+ required: false,
+ },
+ yAxisLabelColor: {
+ types: ['string'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.yAxisLabelColor.help', {
+ defaultMessage: 'Specifies the color of the Y-axis labels.',
+ }),
+ required: false,
+ },
+ // X-axis
+ isXAxisLabelVisible: {
+ types: ['boolean'],
+ help: i18n.translate('xpack.lens.heatmapChart.config.isXAxisLabelVisible.help', {
+ defaultMessage: 'Specifies whether or not the X-axis labels are visible.',
+ }),
+ },
+ },
+ fn(input, args) {
+ return {
+ type: HEATMAP_GRID_FUNCTION,
+ ...args,
+ };
+ },
+};
+
+/**
+ * TODO check if it's possible to make a shared function
+ * based on the XY chart
+ */
+export const heatmapLegendConfig: ExpressionFunctionDefinition<
+ typeof LEGEND_FUNCTION,
+ null,
+ HeatmapLegendConfig,
+ LegendConfigResult
+> = {
+ name: LEGEND_FUNCTION,
+ aliases: [],
+ type: LEGEND_FUNCTION,
+ help: `Configure the heatmap chart's legend`,
+ inputTypes: ['null'],
+ args: {
+ isVisible: {
+ types: ['boolean'],
+ help: i18n.translate('xpack.lens.heatmapChart.legend.isVisible.help', {
+ defaultMessage: 'Specifies whether or not the legend is visible.',
+ }),
+ },
+ position: {
+ types: ['string'],
+ options: [Position.Top, Position.Right, Position.Bottom, Position.Left],
+ help: i18n.translate('xpack.lens.heatmapChart.legend.position.help', {
+ defaultMessage: 'Specifies the legend position.',
+ }),
+ },
+ },
+ fn(input, args) {
+ return {
+ type: LEGEND_FUNCTION,
+ ...args,
+ };
+ },
+};
+
+export const heatmap: ExpressionFunctionDefinition<
+ typeof FUNCTION_NAME,
+ LensMultiTable,
+ HeatmapExpressionArgs,
+ HeatmapRender
+> = {
+ name: FUNCTION_NAME,
+ type: 'render',
+ help: i18n.translate('xpack.lens.heatmap.expressionHelpLabel', {
+ defaultMessage: 'Heatmap renderer',
+ }),
+ args: {
+ title: {
+ types: ['string'],
+ help: i18n.translate('xpack.lens.heatmap.titleLabel', {
+ defaultMessage: 'Title',
+ }),
+ },
+ description: {
+ types: ['string'],
+ help: '',
+ },
+ xAccessor: {
+ types: ['string'],
+ help: '',
+ },
+ yAccessor: {
+ types: ['string'],
+ help: '',
+ },
+ valueAccessor: {
+ types: ['string'],
+ help: '',
+ },
+ shape: {
+ types: ['string'],
+ help: '',
+ },
+ palette: {
+ default: `{theme "palette" default={system_palette name="default"} }`,
+ help: '',
+ types: ['palette'],
+ },
+ legend: {
+ types: [LEGEND_FUNCTION],
+ help: i18n.translate('xpack.lens.heatmapChart.legend.help', {
+ defaultMessage: 'Configure the chart legend.',
+ }),
+ },
+ gridConfig: {
+ types: [HEATMAP_GRID_FUNCTION],
+ help: i18n.translate('xpack.lens.heatmapChart.gridConfig.help', {
+ defaultMessage: 'Configure the heatmap layout.',
+ }),
+ },
+ },
+ inputTypes: ['lens_multitable'],
+ fn(data: LensMultiTable, args: HeatmapExpressionArgs) {
+ return {
+ type: 'render',
+ as: LENS_HEATMAP_RENDERER,
+ value: {
+ data,
+ args,
+ },
+ };
+ },
+};
+
+export const getHeatmapRenderer = (dependencies: {
+ formatFactory: Promise;
+ chartsThemeService: ChartsPluginSetup['theme'];
+ paletteService: PaletteRegistry;
+ timeZone: string;
+}) => ({
+ name: LENS_HEATMAP_RENDERER,
+ displayName: i18n.translate('xpack.lens.heatmap.visualizationName', {
+ defaultMessage: 'Heatmap',
+ }),
+ help: '',
+ validate: () => undefined,
+ reuseDomNode: true,
+ render: async (
+ domNode: Element,
+ config: HeatmapExpressionProps,
+ handlers: IInterpreterRenderHandlers
+ ) => {
+ const formatFactory = await dependencies.formatFactory;
+ const onClickValue = (data: LensFilterEvent['data']) => {
+ handlers.event({ name: 'filter', data });
+ };
+ const onSelectRange = (data: LensBrushEvent['data']) => {
+ handlers.event({ name: 'brush', data });
+ };
+
+ ReactDOM.render(
+
+ {
+
+ }
+ ,
+ domNode,
+ () => {
+ handlers.done();
+ }
+ );
+
+ handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode));
+ },
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts b/x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts
similarity index 52%
rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts
rename to x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts
index 109d3de1b86db..894b003b4b371 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/utils.ts
+++ b/x-pack/plugins/lens/public/heatmap_visualization/heatmap_visualization.ts
@@ -5,8 +5,6 @@
* 2.0.
*/
-import { ROLE_MAPPING_PATH } from '../../routes';
-import { generateEncodedPath } from '../../utils/encode_path_params';
-
-export const generateRoleMappingPath = (roleId: string) =>
- generateEncodedPath(ROLE_MAPPING_PATH, { roleId });
+export * from './expression';
+export * from './types';
+export * from './visualization';
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/index.scss b/x-pack/plugins/lens/public/heatmap_visualization/index.scss
new file mode 100644
index 0000000000000..e72356b1a3d7e
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/index.scss
@@ -0,0 +1,8 @@
+.lnsHeatmapExpression__container {
+ height: 100%;
+ width: 100%;
+ // the FocusTrap is adding extra divs which are making the visualization redraw twice
+ // with a visible glitch. This make the chart library resilient to this extra reflow
+ overflow-x: hidden;
+
+}
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/index.ts b/x-pack/plugins/lens/public/heatmap_visualization/index.ts
new file mode 100644
index 0000000000000..4599bd8d2a208
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/index.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { CoreSetup } from 'kibana/public';
+import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public';
+import { EditorFrameSetup, FormatFactory } from '../types';
+import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
+import { getTimeZone } from '../utils';
+
+export interface HeatmapVisualizationPluginSetupPlugins {
+ expressions: ExpressionsSetup;
+ formatFactory: Promise;
+ editorFrame: EditorFrameSetup;
+ charts: ChartsPluginSetup;
+}
+
+export class HeatmapVisualization {
+ constructor() {}
+
+ setup(
+ core: CoreSetup,
+ { expressions, formatFactory, editorFrame, charts }: HeatmapVisualizationPluginSetupPlugins
+ ) {
+ editorFrame.registerVisualization(async () => {
+ const timeZone = getTimeZone(core.uiSettings);
+
+ const {
+ getHeatmapVisualization,
+ heatmap,
+ heatmapLegendConfig,
+ heatmapGridConfig,
+ getHeatmapRenderer,
+ } = await import('../async_services');
+ const palettes = await charts.palettes.getPalettes();
+
+ expressions.registerFunction(() => heatmap);
+ expressions.registerFunction(() => heatmapLegendConfig);
+ expressions.registerFunction(() => heatmapGridConfig);
+
+ expressions.registerRenderer(
+ getHeatmapRenderer({
+ formatFactory,
+ chartsThemeService: charts.theme,
+ paletteService: palettes,
+ timeZone,
+ })
+ );
+ return getHeatmapVisualization({ paletteService: palettes });
+ });
+ }
+}
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts
new file mode 100644
index 0000000000000..c11078be6c8b9
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts
@@ -0,0 +1,330 @@
+/*
+ * 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 { getSuggestions } from './suggestions';
+import { HeatmapVisualizationState } from './types';
+import { HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants';
+import { Position } from '@elastic/charts';
+
+describe('heatmap suggestions', () => {
+ describe('rejects suggestions', () => {
+ test('when currently active and unchanged data', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [],
+ changeType: 'unchanged',
+ },
+ state: {
+ shape: 'heatmap',
+ layerId: 'first',
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toHaveLength(0);
+ });
+
+ test('when there are 3 or more buckets', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [
+ {
+ columnId: 'date-column-01',
+ operation: {
+ isBucketed: true,
+ dataType: 'date',
+ scale: 'interval',
+ label: 'Date',
+ },
+ },
+ {
+ columnId: 'date-column-02',
+ operation: {
+ isBucketed: true,
+ dataType: 'date',
+ scale: 'interval',
+ label: 'Date',
+ },
+ },
+ {
+ columnId: 'another-bucket-column',
+ operation: {
+ isBucketed: true,
+ dataType: 'string',
+ scale: 'ratio',
+ label: 'Bucket',
+ },
+ },
+ {
+ columnId: 'metric-column',
+ operation: {
+ isBucketed: false,
+ dataType: 'number',
+ scale: 'ratio',
+ label: 'Metric',
+ },
+ },
+ ],
+ changeType: 'initial',
+ },
+ state: {
+ layerId: 'first',
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([]);
+ });
+
+ test('when currently active with partial configuration and not extended change type', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [],
+ changeType: 'initial',
+ },
+ state: {
+ shape: 'heatmap',
+ layerId: 'first',
+ xAccessor: 'some-field',
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toHaveLength(0);
+ });
+ });
+
+ describe('hides suggestions', () => {
+ test('when table is reduced', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [],
+ changeType: 'reduced',
+ },
+ state: {
+ layerId: 'first',
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([
+ {
+ state: {
+ layerId: 'first',
+ shape: 'heatmap',
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ legend: {
+ isVisible: true,
+ position: Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ },
+ title: 'Heatmap',
+ hide: true,
+ previewIcon: 'empty',
+ score: 0,
+ },
+ ]);
+ });
+ test('for tables with a single bucket dimension', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [
+ {
+ columnId: 'test-column',
+ operation: {
+ isBucketed: true,
+ dataType: 'date',
+ scale: 'interval',
+ label: 'Date',
+ },
+ },
+ ],
+ changeType: 'reduced',
+ },
+ state: {
+ layerId: 'first',
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([
+ {
+ state: {
+ layerId: 'first',
+ shape: 'heatmap',
+ xAccessor: 'test-column',
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ legend: {
+ isVisible: true,
+ position: Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ },
+ title: 'Heatmap',
+ hide: true,
+ previewIcon: 'empty',
+ score: 0.3,
+ },
+ ]);
+ });
+ });
+
+ describe('shows suggestions', () => {
+ test('when at least one axis and value accessor are available', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [
+ {
+ columnId: 'date-column',
+ operation: {
+ isBucketed: true,
+ dataType: 'date',
+ scale: 'interval',
+ label: 'Date',
+ },
+ },
+ {
+ columnId: 'metric-column',
+ operation: {
+ isBucketed: false,
+ dataType: 'number',
+ scale: 'ratio',
+ label: 'Metric',
+ },
+ },
+ ],
+ changeType: 'initial',
+ },
+ state: {
+ layerId: 'first',
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([
+ {
+ state: {
+ layerId: 'first',
+ shape: 'heatmap',
+ xAccessor: 'date-column',
+ valueAccessor: 'metric-column',
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ legend: {
+ isVisible: true,
+ position: Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ },
+ title: 'Heatmap',
+ // Temp hide all suggestions while heatmap is in beta
+ hide: true,
+ previewIcon: 'empty',
+ score: 0.6,
+ },
+ ]);
+ });
+
+ test('when complete configuration has been resolved', () => {
+ expect(
+ getSuggestions({
+ table: {
+ layerId: 'first',
+ isMultiRow: true,
+ columns: [
+ {
+ columnId: 'date-column',
+ operation: {
+ isBucketed: true,
+ dataType: 'date',
+ scale: 'interval',
+ label: 'Date',
+ },
+ },
+ {
+ columnId: 'metric-column',
+ operation: {
+ isBucketed: false,
+ dataType: 'number',
+ scale: 'ratio',
+ label: 'Metric',
+ },
+ },
+ {
+ columnId: 'group-column',
+ operation: {
+ isBucketed: true,
+ dataType: 'string',
+ scale: 'ratio',
+ label: 'Group',
+ },
+ },
+ ],
+ changeType: 'initial',
+ },
+ state: {
+ layerId: 'first',
+ } as HeatmapVisualizationState,
+ keptLayerIds: ['first'],
+ })
+ ).toEqual([
+ {
+ state: {
+ layerId: 'first',
+ shape: 'heatmap',
+ xAccessor: 'date-column',
+ yAccessor: 'group-column',
+ valueAccessor: 'metric-column',
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ legend: {
+ isVisible: true,
+ position: Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ },
+ title: 'Heatmap',
+ // Temp hide all suggestions while heatmap is in beta
+ hide: true,
+ previewIcon: 'empty',
+ score: 0.9,
+ },
+ ]);
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts
new file mode 100644
index 0000000000000..5cddebe2cc230
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts
@@ -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 { partition } from 'lodash';
+import { Position } from '@elastic/charts';
+import { i18n } from '@kbn/i18n';
+import { Visualization } from '../types';
+import { HeatmapVisualizationState } from './types';
+import { CHART_SHAPES, HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants';
+
+export const getSuggestions: Visualization['getSuggestions'] = ({
+ table,
+ state,
+ keptLayerIds,
+}) => {
+ if (
+ state?.shape === CHART_SHAPES.HEATMAP &&
+ (state.xAccessor || state.yAccessor || state.valueAccessor) &&
+ table.changeType !== 'extended'
+ ) {
+ return [];
+ }
+
+ const isUnchanged = state && table.changeType === 'unchanged';
+
+ if (
+ isUnchanged ||
+ keptLayerIds.length > 1 ||
+ (keptLayerIds.length && table.layerId !== keptLayerIds[0])
+ ) {
+ return [];
+ }
+
+ /**
+ * The score gets increased based on the config completion.
+ */
+ let score = 0;
+
+ const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed);
+
+ if (groups.length >= 3) {
+ return [];
+ }
+
+ const isSingleBucketDimension = groups.length === 1 && metrics.length === 0;
+
+ /**
+ * Hide for:
+ * - reduced and reorder tables
+ * - tables with just a single bucket dimension
+ */
+ const hide =
+ table.changeType === 'reduced' || table.changeType === 'reorder' || isSingleBucketDimension;
+
+ const newState: HeatmapVisualizationState = {
+ shape: CHART_SHAPES.HEATMAP,
+ layerId: table.layerId,
+ legend: {
+ isVisible: state?.legend?.isVisible ?? true,
+ position: state?.legend?.position ?? Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ };
+
+ const numberMetric = metrics.find((m) => m.operation.dataType === 'number');
+
+ if (numberMetric) {
+ score += 0.3;
+ newState.valueAccessor = numberMetric.columnId;
+ }
+
+ const [histogram, ordinal] = partition(groups, (g) => g.operation.scale === 'interval');
+
+ newState.xAccessor = histogram[0]?.columnId || ordinal[0]?.columnId;
+ newState.yAccessor = groups.find((g) => g.columnId !== newState.xAccessor)?.columnId;
+
+ if (newState.xAccessor) {
+ score += 0.3;
+ }
+ if (newState.yAccessor) {
+ score += 0.3;
+ }
+
+ return [
+ {
+ state: newState,
+ title: i18n.translate('xpack.lens.heatmap.heatmapLabel', {
+ defaultMessage: 'Heatmap',
+ }),
+ // Temp hide all suggestions while heatmap is in beta
+ hide: true || hide,
+ previewIcon: 'empty',
+ score: Number(score.toFixed(1)),
+ },
+ ];
+};
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx
new file mode 100644
index 0000000000000..6fd863ba91936
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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, { memo } from 'react';
+import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { Position } from '@elastic/charts';
+import { i18n } from '@kbn/i18n';
+import { VisualizationToolbarProps } from '../types';
+import { LegendSettingsPopover } from '../shared_components';
+import { HeatmapVisualizationState } from './types';
+
+const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [
+ {
+ id: `heatmap_legend_show`,
+ value: 'show',
+ label: i18n.translate('xpack.lens.heatmapChart.legendVisibility.show', {
+ defaultMessage: 'Show',
+ }),
+ },
+ {
+ id: `heatmap_legend_hide`,
+ value: 'hide',
+ label: i18n.translate('xpack.lens.heatmapChart.legendVisibility.hide', {
+ defaultMessage: 'Hide',
+ }),
+ },
+];
+
+export const HeatmapToolbar = memo(
+ (props: VisualizationToolbarProps) => {
+ const { state, setState } = props;
+
+ const legendMode = state.legend.isVisible ? 'show' : 'hide';
+
+ return (
+
+
+
+ {
+ const newMode = legendOptions.find(({ id }) => id === optionId)!.value;
+ if (newMode === 'show') {
+ setState({
+ ...state,
+ legend: { ...state.legend, isVisible: true },
+ });
+ } else if (newMode === 'hide') {
+ setState({
+ ...state,
+ legend: { ...state.legend, isVisible: false },
+ });
+ }
+ }}
+ position={state?.legend.position}
+ onPositionChange={(id) => {
+ setState({
+ ...state,
+ legend: { ...state.legend, position: id as Position },
+ });
+ }}
+ />
+
+
+
+ );
+ }
+);
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/types.ts b/x-pack/plugins/lens/public/heatmap_visualization/types.ts
new file mode 100644
index 0000000000000..734fe7f5be754
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/types.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 { Position } from '@elastic/charts';
+import { PaletteOutput } from '../../../../../src/plugins/charts/common';
+import { FormatFactory, LensBrushEvent, LensFilterEvent, LensMultiTable } from '../types';
+import {
+ CHART_SHAPES,
+ HEATMAP_GRID_FUNCTION,
+ LEGEND_FUNCTION,
+ LENS_HEATMAP_RENDERER,
+} from './constants';
+import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
+
+export type ChartShapes = typeof CHART_SHAPES[keyof typeof CHART_SHAPES];
+
+export interface SharedHeatmapLayerState {
+ shape: ChartShapes;
+ xAccessor?: string;
+ yAccessor?: string;
+ valueAccessor?: string;
+ legend: LegendConfigResult;
+ gridConfig: HeatmapGridConfigResult;
+}
+
+export type HeatmapLayerState = SharedHeatmapLayerState & {
+ layerId: string;
+};
+
+export type HeatmapVisualizationState = HeatmapLayerState & {
+ palette?: PaletteOutput;
+};
+
+export type HeatmapExpressionArgs = SharedHeatmapLayerState & {
+ title?: string;
+ description?: string;
+ palette: PaletteOutput;
+};
+
+export interface HeatmapRender {
+ type: 'render';
+ as: typeof LENS_HEATMAP_RENDERER;
+ value: HeatmapExpressionProps;
+}
+
+export interface HeatmapExpressionProps {
+ data: LensMultiTable;
+ args: HeatmapExpressionArgs;
+}
+
+export type HeatmapRenderProps = HeatmapExpressionProps & {
+ timeZone: string;
+ formatFactory: FormatFactory;
+ chartsThemeService: ChartsPluginSetup['theme'];
+ onClickValue: (data: LensFilterEvent['data']) => void;
+ onSelectRange: (data: LensBrushEvent['data']) => void;
+};
+
+export interface HeatmapLegendConfig {
+ /**
+ * Flag whether the legend should be shown. If there is just a single series, it will be hidden
+ */
+ isVisible: boolean;
+ /**
+ * Position of the legend relative to the chart
+ */
+ position: Position;
+}
+
+export type LegendConfigResult = HeatmapLegendConfig & { type: typeof LEGEND_FUNCTION };
+
+export interface HeatmapGridConfig {
+ // grid
+ strokeWidth?: number;
+ strokeColor?: string;
+ cellHeight?: number;
+ cellWidth?: number;
+ // cells
+ isCellLabelVisible: boolean;
+ // Y-axis
+ isYAxisLabelVisible: boolean;
+ yAxisLabelWidth?: number;
+ yAxisLabelColor?: string;
+ // X-axis
+ isXAxisLabelVisible: boolean;
+}
+
+export type HeatmapGridConfigResult = HeatmapGridConfig & { type: typeof HEATMAP_GRID_FUNCTION };
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts
new file mode 100644
index 0000000000000..3ed82bef06105
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts
@@ -0,0 +1,486 @@
+/*
+ * 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 {
+ filterOperationsAxis,
+ getHeatmapVisualization,
+ isCellValueSupported,
+} from './visualization';
+import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks';
+import {
+ CHART_SHAPES,
+ FUNCTION_NAME,
+ GROUP_ID,
+ HEATMAP_GRID_FUNCTION,
+ LEGEND_FUNCTION,
+} from './constants';
+import { Position } from '@elastic/charts';
+import { HeatmapVisualizationState } from './types';
+import { DatasourcePublicAPI, Operation } from '../types';
+
+function exampleState(): HeatmapVisualizationState {
+ return {
+ layerId: 'test-layer',
+ legend: {
+ isVisible: true,
+ position: Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ shape: CHART_SHAPES.HEATMAP,
+ };
+}
+
+describe('heatmap', () => {
+ let frame: ReturnType;
+
+ beforeEach(() => {
+ frame = createMockFramePublicAPI();
+ });
+
+ describe('#intialize', () => {
+ test('returns a default state', () => {
+ expect(getHeatmapVisualization({}).initialize(frame)).toEqual({
+ layerId: '',
+ title: 'Empty Heatmap chart',
+ shape: CHART_SHAPES.HEATMAP,
+ legend: {
+ isVisible: true,
+ position: Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ });
+ });
+
+ test('returns persisted state', () => {
+ expect(getHeatmapVisualization({}).initialize(frame, exampleState())).toEqual(exampleState());
+ });
+ });
+
+ describe('#getConfiguration', () => {
+ beforeEach(() => {
+ const mockDatasource = createMockDatasource('testDatasource');
+
+ mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
+ dataType: 'string',
+ label: 'MyOperation',
+ } as Operation);
+
+ frame.datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ });
+
+ test('resolves configuration from complete state', () => {
+ const state: HeatmapVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ xAccessor: 'x-accessor',
+ yAccessor: 'y-accessor',
+ valueAccessor: 'v-accessor',
+ };
+
+ expect(
+ getHeatmapVisualization({}).getConfiguration({ state, frame, layerId: 'first' })
+ ).toEqual({
+ groups: [
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.X,
+ groupLabel: 'Horizontal axis',
+ accessors: [{ columnId: 'x-accessor' }],
+ filterOperations: filterOperationsAxis,
+ supportsMoreColumns: false,
+ required: true,
+ dataTestSubj: 'lnsHeatmap_xDimensionPanel',
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.Y,
+ groupLabel: 'Vertical axis',
+ accessors: [{ columnId: 'y-accessor' }],
+ filterOperations: filterOperationsAxis,
+ supportsMoreColumns: false,
+ required: false,
+ dataTestSubj: 'lnsHeatmap_yDimensionPanel',
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.CELL,
+ groupLabel: 'Cell value',
+ accessors: [{ columnId: 'v-accessor' }],
+ filterOperations: isCellValueSupported,
+ supportsMoreColumns: false,
+ required: true,
+ dataTestSubj: 'lnsHeatmap_cellPanel',
+ },
+ ],
+ });
+ });
+
+ test('resolves configuration from partial state', () => {
+ const state: HeatmapVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ xAccessor: 'x-accessor',
+ };
+
+ expect(
+ getHeatmapVisualization({}).getConfiguration({ state, frame, layerId: 'first' })
+ ).toEqual({
+ groups: [
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.X,
+ groupLabel: 'Horizontal axis',
+ accessors: [{ columnId: 'x-accessor' }],
+ filterOperations: filterOperationsAxis,
+ supportsMoreColumns: false,
+ required: true,
+ dataTestSubj: 'lnsHeatmap_xDimensionPanel',
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.Y,
+ groupLabel: 'Vertical axis',
+ accessors: [],
+ filterOperations: filterOperationsAxis,
+ supportsMoreColumns: true,
+ required: false,
+ dataTestSubj: 'lnsHeatmap_yDimensionPanel',
+ },
+ {
+ layerId: 'first',
+ groupId: GROUP_ID.CELL,
+ groupLabel: 'Cell value',
+ accessors: [],
+ filterOperations: isCellValueSupported,
+ supportsMoreColumns: true,
+ required: true,
+ dataTestSubj: 'lnsHeatmap_cellPanel',
+ },
+ ],
+ });
+ });
+ });
+
+ describe('#setDimension', () => {
+ test('set dimension correctly', () => {
+ const prevState: HeatmapVisualizationState = {
+ ...exampleState(),
+ xAccessor: 'x-accessor',
+ yAccessor: 'y-accessor',
+ };
+ expect(
+ getHeatmapVisualization({}).setDimension({
+ prevState,
+ layerId: 'first',
+ columnId: 'new-x-accessor',
+ groupId: 'x',
+ })
+ ).toEqual({
+ ...prevState,
+ xAccessor: 'new-x-accessor',
+ });
+ });
+ });
+
+ describe('#removeDimension', () => {
+ test('removes dimension correctly', () => {
+ const prevState: HeatmapVisualizationState = {
+ ...exampleState(),
+ xAccessor: 'x-accessor',
+ yAccessor: 'y-accessor',
+ };
+ expect(
+ getHeatmapVisualization({}).removeDimension({
+ prevState,
+ layerId: 'first',
+ columnId: 'x-accessor',
+ })
+ ).toEqual({
+ ...exampleState(),
+ yAccessor: 'y-accessor',
+ });
+ });
+ });
+
+ describe('#toExpression', () => {
+ let datasourceLayers: Record;
+
+ beforeEach(() => {
+ const mockDatasource = createMockDatasource('testDatasource');
+
+ mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
+ dataType: 'string',
+ label: 'MyOperation',
+ } as Operation);
+
+ datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ });
+
+ test('creates an expression based on state and attributes', () => {
+ const state: HeatmapVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ xAccessor: 'x-accessor',
+ valueAccessor: 'value-accessor',
+ };
+ const attributes = {
+ title: 'Test',
+ };
+
+ expect(getHeatmapVisualization({}).toExpression(state, datasourceLayers, attributes)).toEqual(
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: FUNCTION_NAME,
+ arguments: {
+ title: ['Test'],
+ description: [''],
+ xAccessor: ['x-accessor'],
+ yAccessor: [''],
+ valueAccessor: ['value-accessor'],
+ legend: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: LEGEND_FUNCTION,
+ arguments: {
+ isVisible: [true],
+ position: [Position.Right],
+ },
+ },
+ ],
+ },
+ ],
+ gridConfig: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: HEATMAP_GRID_FUNCTION,
+ arguments: {
+ // grid
+ strokeWidth: [],
+ strokeColor: [],
+ cellHeight: [],
+ cellWidth: [],
+ // cells
+ isCellLabelVisible: [false],
+ // Y-axis
+ isYAxisLabelVisible: [true],
+ yAxisLabelWidth: [],
+ yAxisLabelColor: [],
+ // X-axis
+ isXAxisLabelVisible: [true],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ }
+ );
+ });
+
+ test('returns null with a missing value accessor', () => {
+ const state: HeatmapVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ xAccessor: 'x-accessor',
+ };
+ const attributes = {
+ title: 'Test',
+ };
+
+ expect(getHeatmapVisualization({}).toExpression(state, datasourceLayers, attributes)).toEqual(
+ null
+ );
+ });
+ });
+
+ describe('#toPreviewExpression', () => {
+ let datasourceLayers: Record;
+
+ beforeEach(() => {
+ const mockDatasource = createMockDatasource('testDatasource');
+
+ mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
+ dataType: 'string',
+ label: 'MyOperation',
+ } as Operation);
+
+ datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ });
+
+ test('creates a preview expression based on state and attributes', () => {
+ const state: HeatmapVisualizationState = {
+ ...exampleState(),
+ layerId: 'first',
+ xAccessor: 'x-accessor',
+ };
+
+ expect(getHeatmapVisualization({}).toPreviewExpression!(state, datasourceLayers)).toEqual({
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: FUNCTION_NAME,
+ arguments: {
+ title: [''],
+ description: [''],
+ xAccessor: ['x-accessor'],
+ yAccessor: [''],
+ valueAccessor: [''],
+ legend: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: LEGEND_FUNCTION,
+ arguments: {
+ isVisible: [false],
+ position: [],
+ },
+ },
+ ],
+ },
+ ],
+ gridConfig: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: HEATMAP_GRID_FUNCTION,
+ arguments: {
+ // grid
+ strokeWidth: [1],
+ // cells
+ isCellLabelVisible: [false],
+ // Y-axis
+ isYAxisLabelVisible: [false],
+ // X-axis
+ isXAxisLabelVisible: [false],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ });
+ });
+ });
+
+ describe('#getErrorMessages', () => {
+ test('should not return an error when chart has empty configuration', () => {
+ const mockState = {
+ shape: CHART_SHAPES.HEATMAP,
+ } as HeatmapVisualizationState;
+ expect(getHeatmapVisualization({}).getErrorMessages(mockState)).toEqual(undefined);
+ });
+
+ test('should return an error when the X accessor is missing', () => {
+ const mockState = {
+ shape: CHART_SHAPES.HEATMAP,
+ valueAccessor: 'v-accessor',
+ } as HeatmapVisualizationState;
+ expect(getHeatmapVisualization({}).getErrorMessages(mockState)).toEqual([
+ {
+ longMessage: 'Configuration for the horizontal axis is missing.',
+ shortMessage: 'Missing Horizontal axis.',
+ },
+ ]);
+ });
+ });
+
+ describe('#getWarningMessages', () => {
+ beforeEach(() => {
+ const mockDatasource = createMockDatasource('testDatasource');
+
+ mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
+ dataType: 'string',
+ label: 'MyOperation',
+ } as Operation);
+
+ frame.datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ });
+
+ test('should not return warning messages when the layer it not configured', () => {
+ const mockState = {
+ shape: CHART_SHAPES.HEATMAP,
+ valueAccessor: 'v-accessor',
+ } as HeatmapVisualizationState;
+ expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toEqual(undefined);
+ });
+
+ test('should not return warning messages when the data table is empty', () => {
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ rows: [],
+ columns: [],
+ },
+ };
+ const mockState = {
+ shape: CHART_SHAPES.HEATMAP,
+ valueAccessor: 'v-accessor',
+ layerId: 'first',
+ } as HeatmapVisualizationState;
+ expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toEqual(undefined);
+ });
+
+ test('should return a warning message when cell value data contains arrays', () => {
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ rows: [
+ {
+ 'v-accessor': [1, 2, 3],
+ },
+ ],
+ columns: [],
+ },
+ };
+
+ const mockState = {
+ shape: CHART_SHAPES.HEATMAP,
+ valueAccessor: 'v-accessor',
+ layerId: 'first',
+ } as HeatmapVisualizationState;
+ expect(getHeatmapVisualization({}).getWarningMessages!(mockState, frame)).toHaveLength(1);
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx
new file mode 100644
index 0000000000000..54f9c70824831
--- /dev/null
+++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx
@@ -0,0 +1,415 @@
+/*
+ * 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 { render } from 'react-dom';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
+import { Ast } from '@kbn/interpreter/common';
+import { Position } from '@elastic/charts';
+import { PaletteRegistry } from '../../../../../src/plugins/charts/public';
+import { OperationMetadata, Visualization } from '../types';
+import { HeatmapVisualizationState } from './types';
+import { getSuggestions } from './suggestions';
+import {
+ CHART_NAMES,
+ CHART_SHAPES,
+ FUNCTION_NAME,
+ GROUP_ID,
+ HEATMAP_GRID_FUNCTION,
+ LEGEND_FUNCTION,
+ LENS_HEATMAP_ID,
+} from './constants';
+import { HeatmapToolbar } from './toolbar_component';
+import { LensIconChartHeatmap } from '../assets/chart_heatmap';
+
+const groupLabelForBar = i18n.translate('xpack.lens.heatmapVisualization.heatmapGroupLabel', {
+ defaultMessage: 'Heatmap',
+});
+
+interface HeatmapVisualizationDeps {
+ paletteService?: PaletteRegistry;
+}
+
+function getAxisName(axis: 'x' | 'y') {
+ const vertical = i18n.translate('xpack.lens.heatmap.verticalAxisLabel', {
+ defaultMessage: 'Vertical axis',
+ });
+ const horizontal = i18n.translate('xpack.lens.heatmap.horizontalAxisLabel', {
+ defaultMessage: 'Horizontal axis',
+ });
+ if (axis === 'x') {
+ return horizontal;
+ }
+ return vertical;
+}
+
+export const isBucketed = (op: OperationMetadata) => op.isBucketed && op.scale === 'ordinal';
+const isNumericMetric = (op: OperationMetadata) => op.dataType === 'number';
+
+export const filterOperationsAxis = (op: OperationMetadata) =>
+ isBucketed(op) || op.scale === 'interval';
+
+export const isCellValueSupported = (op: OperationMetadata) => {
+ return !isBucketed(op) && (op.scale === 'ordinal' || op.scale === 'ratio') && isNumericMetric(op);
+};
+
+function getInitialState(): Omit {
+ return {
+ shape: CHART_SHAPES.HEATMAP,
+ legend: {
+ isVisible: true,
+ position: Position.Right,
+ type: LEGEND_FUNCTION,
+ },
+ gridConfig: {
+ type: HEATMAP_GRID_FUNCTION,
+ isCellLabelVisible: false,
+ isYAxisLabelVisible: true,
+ isXAxisLabelVisible: true,
+ },
+ };
+}
+
+export const getHeatmapVisualization = ({
+ paletteService,
+}: HeatmapVisualizationDeps): Visualization => ({
+ id: LENS_HEATMAP_ID,
+
+ visualizationTypes: [
+ {
+ id: 'heatmap',
+ icon: LensIconChartHeatmap,
+ label: i18n.translate('xpack.lens.heatmapVisualization.heatmapLabel', {
+ defaultMessage: 'Heatmap',
+ }),
+ groupLabel: groupLabelForBar,
+ showBetaBadge: true,
+ },
+ ],
+
+ getVisualizationTypeId(state) {
+ return state.shape;
+ },
+
+ getLayerIds(state) {
+ return [state.layerId];
+ },
+
+ clearLayer(state) {
+ const newState = { ...state };
+ delete newState.valueAccessor;
+ delete newState.xAccessor;
+ delete newState.yAccessor;
+ return newState;
+ },
+
+ switchVisualizationType: (visualizationTypeId, state) => {
+ return {
+ ...state,
+ shape: visualizationTypeId as typeof CHART_SHAPES.HEATMAP,
+ };
+ },
+
+ getDescription(state) {
+ return CHART_NAMES.heatmap;
+ },
+
+ initialize(frame, state, mainPalette) {
+ return (
+ state || {
+ layerId: frame.addNewLayer(),
+ title: 'Empty Heatmap chart',
+ ...getInitialState(),
+ }
+ );
+ },
+
+ getSuggestions,
+
+ getConfiguration({ state, frame, layerId }) {
+ const datasourceLayer = frame.datasourceLayers[layerId];
+
+ const originalOrder = datasourceLayer.getTableSpec().map(({ columnId }) => columnId);
+ if (!originalOrder) {
+ return { groups: [] };
+ }
+
+ return {
+ groups: [
+ {
+ layerId: state.layerId,
+ groupId: GROUP_ID.X,
+ groupLabel: getAxisName(GROUP_ID.X),
+ accessors: state.xAccessor ? [{ columnId: state.xAccessor }] : [],
+ filterOperations: filterOperationsAxis,
+ supportsMoreColumns: !state.xAccessor,
+ required: true,
+ dataTestSubj: 'lnsHeatmap_xDimensionPanel',
+ },
+ {
+ layerId: state.layerId,
+ groupId: GROUP_ID.Y,
+ groupLabel: getAxisName(GROUP_ID.Y),
+ accessors: state.yAccessor ? [{ columnId: state.yAccessor }] : [],
+ filterOperations: filterOperationsAxis,
+ supportsMoreColumns: !state.yAccessor,
+ required: false,
+ dataTestSubj: 'lnsHeatmap_yDimensionPanel',
+ },
+ {
+ layerId: state.layerId,
+ groupId: GROUP_ID.CELL,
+ groupLabel: i18n.translate('xpack.lens.heatmap.cellValueLabel', {
+ defaultMessage: 'Cell value',
+ }),
+ accessors: state.valueAccessor ? [{ columnId: state.valueAccessor }] : [],
+ filterOperations: isCellValueSupported,
+ supportsMoreColumns: !state.valueAccessor,
+ required: true,
+ dataTestSubj: 'lnsHeatmap_cellPanel',
+ },
+ ],
+ };
+ },
+
+ setDimension({ prevState, layerId, columnId, groupId, previousColumn }) {
+ const update: Partial = {};
+ if (groupId === GROUP_ID.X) {
+ update.xAccessor = columnId;
+ }
+ if (groupId === GROUP_ID.Y) {
+ update.yAccessor = columnId;
+ }
+ if (groupId === GROUP_ID.CELL) {
+ update.valueAccessor = columnId;
+ }
+ return {
+ ...prevState,
+ ...update,
+ };
+ },
+
+ removeDimension({ prevState, layerId, columnId }) {
+ const update = { ...prevState };
+
+ if (prevState.valueAccessor === columnId) {
+ delete update.valueAccessor;
+ }
+ if (prevState.xAccessor === columnId) {
+ delete update.xAccessor;
+ }
+ if (prevState.yAccessor === columnId) {
+ delete update.yAccessor;
+ }
+
+ return update;
+ },
+
+ renderToolbar(domElement, props) {
+ render(
+
+
+ ,
+ domElement
+ );
+ },
+
+ toExpression(state, datasourceLayers, attributes): Ast | null {
+ const datasource = datasourceLayers[state.layerId];
+
+ const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
+ // When we add a column it could be empty, and therefore have no order
+
+ if (!originalOrder || !state.valueAccessor) {
+ return null;
+ }
+
+ return {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: FUNCTION_NAME,
+ arguments: {
+ title: [attributes?.title ?? ''],
+ description: [attributes?.description ?? ''],
+ xAccessor: [state.xAccessor ?? ''],
+ yAccessor: [state.yAccessor ?? ''],
+ valueAccessor: [state.valueAccessor ?? ''],
+ legend: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: LEGEND_FUNCTION,
+ arguments: {
+ isVisible: [state.legend.isVisible],
+ position: [state.legend.position],
+ },
+ },
+ ],
+ },
+ ],
+ gridConfig: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: HEATMAP_GRID_FUNCTION,
+ arguments: {
+ // grid
+ strokeWidth: state.gridConfig.strokeWidth
+ ? [state.gridConfig.strokeWidth]
+ : [],
+ strokeColor: state.gridConfig.strokeColor
+ ? [state.gridConfig.strokeColor]
+ : [],
+ cellHeight: state.gridConfig.cellHeight ? [state.gridConfig.cellHeight] : [],
+ cellWidth: state.gridConfig.cellWidth ? [state.gridConfig.cellWidth] : [],
+ // cells
+ isCellLabelVisible: [state.gridConfig.isCellLabelVisible],
+ // Y-axis
+ isYAxisLabelVisible: [state.gridConfig.isYAxisLabelVisible],
+ yAxisLabelWidth: state.gridConfig.yAxisLabelWidth
+ ? [state.gridConfig.yAxisLabelWidth]
+ : [],
+ yAxisLabelColor: state.gridConfig.yAxisLabelColor
+ ? [state.gridConfig.yAxisLabelColor]
+ : [],
+ // X-axis
+ isXAxisLabelVisible: state.gridConfig.isXAxisLabelVisible
+ ? [state.gridConfig.isXAxisLabelVisible]
+ : [],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ };
+ },
+
+ toPreviewExpression(state, datasourceLayers): Ast | null {
+ const datasource = datasourceLayers[state.layerId];
+
+ const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
+ // When we add a column it could be empty, and therefore have no order
+
+ if (!originalOrder) {
+ return null;
+ }
+
+ return {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: FUNCTION_NAME,
+ arguments: {
+ title: [''],
+ description: [''],
+ xAccessor: [state.xAccessor ?? ''],
+ yAccessor: [state.yAccessor ?? ''],
+ valueAccessor: [state.valueAccessor ?? ''],
+ legend: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: LEGEND_FUNCTION,
+ arguments: {
+ isVisible: [false],
+ position: [],
+ },
+ },
+ ],
+ },
+ ],
+ gridConfig: [
+ {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: HEATMAP_GRID_FUNCTION,
+ arguments: {
+ // grid
+ strokeWidth: [1],
+ // cells
+ isCellLabelVisible: [false],
+ // Y-axis
+ isYAxisLabelVisible: [false],
+ // X-axis
+ isXAxisLabelVisible: [false],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ };
+ },
+
+ getErrorMessages(state) {
+ if (!state.yAccessor && !state.xAccessor && !state.valueAccessor) {
+ // nothing configured yet
+ return;
+ }
+
+ const errors: ReturnType = [];
+
+ if (!state.xAccessor) {
+ errors.push({
+ shortMessage: i18n.translate(
+ 'xpack.lens.heatmapVisualization.missingXAccessorShortMessage',
+ {
+ defaultMessage: 'Missing Horizontal axis.',
+ }
+ ),
+ longMessage: i18n.translate('xpack.lens.heatmapVisualization.missingXAccessorLongMessage', {
+ defaultMessage: 'Configuration for the horizontal axis is missing.',
+ }),
+ });
+ }
+
+ return errors.length ? errors : undefined;
+ },
+
+ getWarningMessages(state, frame) {
+ if (!state?.layerId || !frame.activeData || !state.valueAccessor) {
+ return;
+ }
+
+ const rows = frame.activeData[state.layerId] && frame.activeData[state.layerId].rows;
+ if (!rows) {
+ return;
+ }
+
+ const hasArrayValues = rows.some((row) => Array.isArray(row[state.valueAccessor!]));
+
+ const datasource = frame.datasourceLayers[state.layerId];
+ const operation = datasource.getOperationForColumnId(state.valueAccessor);
+
+ return hasArrayValues
+ ? [
+ {operation?.label} }}
+ />,
+ ]
+ : undefined;
+ },
+});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx
index 65bc23b4eb1ca..68705ebf2d157 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx
@@ -114,7 +114,7 @@ export function Filtering({
}
>
{
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx
index 6d1cc3254ca7e..1c2e64735ca16 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx
@@ -13,6 +13,7 @@ import { createMockedIndexPattern } from '../../../mocks';
import { FilterPopover } from './filter_popover';
import { LabelInput } from '../shared_components';
import { QueryInput } from '../../../query_input';
+import { QueryStringInput } from '../../../../../../../../src/plugins/data/public';
jest.mock('.', () => ({
isQueryValid: () => true,
@@ -32,13 +33,25 @@ const defaultProps = {
),
initiallyOpen: true,
};
+jest.mock('../../../../../../../../src/plugins/data/public', () => ({
+ QueryStringInput: () => {
+ return 'QueryStringInput';
+ },
+}));
describe('filter popover', () => {
- jest.mock('../../../../../../../../src/plugins/data/public', () => ({
- QueryStringInput: () => {
- return 'QueryStringInput';
- },
- }));
+ it('passes correct props to QueryStringInput', () => {
+ const instance = mount();
+ instance.update();
+ expect(instance.find(QueryStringInput).props()).toEqual(
+ expect.objectContaining({
+ dataTestSubj: 'indexPattern-filters-queryStringInput',
+ indexPatterns: ['my-fake-index-pattern'],
+ isInvalid: false,
+ query: { language: 'kuery', query: 'bytes >= 1' },
+ })
+ );
+ });
it('should be open if is open by creation', () => {
const instance = mount();
instance.update();
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx
index f5428bf24348f..bfb0cffece57c 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx
@@ -75,7 +75,7 @@ export const FilterPopover = ({
{
if (inputRef.current) inputRef.current.focus();
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx
index 6c2b62f96eaec..a67199a9d3432 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx
@@ -7,21 +7,20 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
-import { IndexPattern } from './types';
import { QueryStringInput, Query } from '../../../../../src/plugins/data/public';
import { useDebouncedValue } from '../shared_components';
export const QueryInput = ({
value,
onChange,
- indexPattern,
+ indexPatternTitle,
isInvalid,
onSubmit,
disableAutoFocus,
}: {
value: Query;
onChange: (input: Query) => void;
- indexPattern: IndexPattern;
+ indexPatternTitle: string;
isInvalid: boolean;
onSubmit: () => void;
disableAutoFocus?: boolean;
@@ -35,7 +34,7 @@ export const QueryInput = ({
disableAutoFocus={disableAutoFocus}
isInvalid={isInvalid}
bubbleSubmitEvent={false}
- indexPatterns={[indexPattern]}
+ indexPatterns={[indexPatternTitle]}
query={inputValue}
onChange={handleInputChange}
onSubmit={() => {
diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts
index 99e7199c2d802..fe225dba6f256 100644
--- a/x-pack/plugins/lens/public/plugin.ts
+++ b/x-pack/plugins/lens/public/plugin.ts
@@ -54,6 +54,7 @@ import {
EmbeddableComponentProps,
getEmbeddableComponent,
} from './editor_frame_service/embeddable/embeddable_component';
+import { HeatmapVisualization } from './heatmap_visualization';
export interface LensPluginSetupDependencies {
urlForwarding: UrlForwardingSetup;
@@ -119,6 +120,7 @@ export class LensPlugin {
private xyVisualization: XyVisualization;
private metricVisualization: MetricVisualization;
private pieVisualization: PieVisualization;
+ private heatmapVisualization: HeatmapVisualization;
private stopReportManager?: () => void;
@@ -129,6 +131,7 @@ export class LensPlugin {
this.xyVisualization = new XyVisualization();
this.metricVisualization = new MetricVisualization();
this.pieVisualization = new PieVisualization();
+ this.heatmapVisualization = new HeatmapVisualization();
}
setup(
@@ -178,6 +181,7 @@ export class LensPlugin {
this.datatableVisualization.setup(core, dependencies);
this.metricVisualization.setup(core, dependencies);
this.pieVisualization.setup(core, dependencies);
+ this.heatmapVisualization.setup(core, dependencies);
visualizations.registerAlias(getLensAliasConfig());
diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx
index 7acd5669b4ba5..23d4858c26263 100644
--- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx
+++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx
@@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiButtonGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui';
import { Position } from '@elastic/charts';
import { ToolbarPopover } from '../shared_components';
+import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public';
export interface LegendSettingsPopoverProps {
/**
@@ -44,6 +45,10 @@ export interface LegendSettingsPopoverProps {
* Callback on nested switch status change
*/
onNestedLegendChange?: (event: EuiSwitchEvent) => void;
+ /**
+ * Button group position
+ */
+ groupPosition?: ToolbarButtonProps['groupPosition'];
}
const toggleButtonsIcons = [
@@ -86,6 +91,7 @@ export const LegendSettingsPopover: React.FunctionComponent {},
+ groupPosition = 'right',
}) => {
return (
{
diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts
index c1aab4c18f529..2706fe977c68e 100644
--- a/x-pack/plugins/lens/public/utils.ts
+++ b/x-pack/plugins/lens/public/utils.ts
@@ -7,6 +7,8 @@
import { i18n } from '@kbn/i18n';
import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plugins/data/public';
+import { IUiSettingsClient } from 'kibana/public';
+import moment from 'moment-timezone';
import { LensFilterEvent } from './types';
/** replaces the value `(empty) to empty string for proper filtering` */
@@ -63,6 +65,7 @@ export const getResolvedDateRange = function (timefilter: TimefilterContract) {
export function containsDynamicMath(dateMathString: string) {
return dateMathString.includes('now');
}
+
export const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
export async function getAllIndexPatterns(
@@ -79,3 +82,12 @@ export async function getAllIndexPatterns(
// return also the rejected ids in case we want to show something later on
return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds };
}
+
+export function getTimeZone(uiSettings: IUiSettingsClient) {
+ const configuredTimeZone = uiSettings.get('dateFormat:tz');
+ if (configuredTimeZone === 'Browser') {
+ return moment.tz.guess();
+ }
+
+ return configuredTimeZone;
+}
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
index 608971d281981..9b203faee3a64 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
@@ -222,7 +222,7 @@ export const xyChart: ExpressionFunctionDefinition<
},
};
-export async function calculateMinInterval({ args: { layers }, data }: XYChartProps) {
+export function calculateMinInterval({ args: { layers }, data }: XYChartProps) {
const filteredLayers = getFilteredLayers(layers, data);
if (filteredLayers.length === 0) return;
const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time');
@@ -280,7 +280,7 @@ export const getXyChartRenderer = (dependencies: {
chartsThemeService={dependencies.chartsThemeService}
paletteService={dependencies.paletteService}
timeZone={dependencies.timeZone}
- minInterval={await calculateMinInterval(config)}
+ minInterval={calculateMinInterval(config)}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
renderMode={handlers.getRenderMode()}
diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts
index bb9893bd058b5..f29d0f9280246 100644
--- a/x-pack/plugins/lens/public/xy_visualization/index.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/index.ts
@@ -5,12 +5,12 @@
* 2.0.
*/
-import { CoreSetup, IUiSettingsClient } from 'kibana/public';
-import moment from 'moment-timezone';
+import { CoreSetup } from 'kibana/public';
import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public';
import { EditorFrameSetup, FormatFactory } from '../types';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { LensPluginStartDependencies } from '../plugin';
+import { getTimeZone } from '../utils';
export interface XyVisualizationPluginSetupPlugins {
expressions: ExpressionsSetup;
@@ -19,15 +19,6 @@ export interface XyVisualizationPluginSetupPlugins {
charts: ChartsPluginSetup;
}
-function getTimeZone(uiSettings: IUiSettingsClient) {
- const configuredTimeZone = uiSettings.get('dateFormat:tz');
- if (configuredTimeZone === 'Browser') {
- return moment.tz.guess();
- }
-
- return configuredTimeZone;
-}
-
export class XyVisualization {
constructor() {}
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
index c1041e1fefcfd..f2840b6d3844b 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
@@ -52,7 +52,7 @@ describe('xy_visualization', () => {
};
}
- it('should show mixed xy chart when multilple series types', () => {
+ it('should show mixed xy chart when multiple series types', () => {
const desc = xyVisualization.getDescription(mixedState('bar', 'line'));
expect(desc.label).toEqual('Mixed XY');
@@ -332,7 +332,7 @@ describe('xy_visualization', () => {
expect(options.map((o) => o.groupId)).toEqual(['x', 'y', 'breakdown']);
});
- it('should return the correct labels for the 3 dimensios', () => {
+ it('should return the correct labels for the 3 dimensions', () => {
const options = xyVisualization.getConfiguration({
state: exampleState(),
frame,
@@ -345,7 +345,7 @@ describe('xy_visualization', () => {
]);
});
- it('should return the correct labels for the 3 dimensios for a horizontal chart', () => {
+ it('should return the correct labels for the 3 dimensions for a horizontal chart', () => {
const initialState = exampleState();
const state = {
...initialState,
diff --git a/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts
new file mode 100644
index 0000000000000..64250d0b3c5ae
--- /dev/null
+++ b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts
@@ -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 { ESGlobPatterns } from './es_glob_patterns';
+
+const testIndices = [
+ '.kibana_task_manager_inifc_1',
+ '.kibana_shahzad_4',
+ '.kibana_shahzad_3',
+ '.kibana_shahzad_2',
+ '.kibana_shahzad_1',
+ '.kibana_task_manager_cmarcondes-24_8.0.0_001',
+ '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001',
+ '.kibana_task_manager_spong_8.0.0_001',
+ '.ds-metrics-system.process.summary-default-2021.05.25-00000',
+ '.kibana_shahzad_9',
+ '.kibana-felix-log-stream_8.0.0_001',
+ '.kibana_smith_alerts-observability-apm-000001',
+ '.ds-logs-endpoint.events.process-default-2021.05.26-000001',
+ '.kibana_dominiqueclarke54_8.0.0_001',
+ '.kibana-cmarcondes-19_8.0.0_001',
+ '.kibana_task_manager_cmarcondes-17_8.0.0_001',
+ '.kibana_task_manager_jrhodes_8.0.0_001',
+ '.kibana_task_manager_dominiqueclarke7_8',
+ 'data_prod_0',
+ 'data_prod_1',
+ 'data_prod_2',
+ 'data_prod_3',
+ 'filebeat-8.0.0-2021.04.13-000001',
+ '.kibana_dominiqueclarke55-alerts-8.0.0-000001',
+ '.ds-metrics-system.socket_summary-default-2021.05.12-000001',
+ '.kibana_task_manager_dominiqueclarke24_8.0.0_001',
+ '.kibana_custom_kbn-pr94906_8.0.0_001',
+ '.kibana_task_manager_cmarcondes-22_8.0.0_001',
+ '.kibana_dominiqueclarke49-event-log-8.0.0-000001',
+ 'data_stage_2',
+ 'data_stage_3',
+].sort();
+
+const noSystemIndices = [
+ 'data_prod_0',
+ 'data_prod_1',
+ 'data_prod_2',
+ 'data_prod_3',
+ 'filebeat-8.0.0-2021.04.13-000001',
+ 'data_stage_2',
+ 'data_stage_3',
+].sort();
+
+const onlySystemIndices = [
+ '.kibana_task_manager_inifc_1',
+ '.kibana_shahzad_4',
+ '.kibana_shahzad_3',
+ '.kibana_shahzad_2',
+ '.kibana_shahzad_1',
+ '.kibana_task_manager_cmarcondes-24_8.0.0_001',
+ '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001',
+ '.kibana_task_manager_spong_8.0.0_001',
+ '.ds-metrics-system.process.summary-default-2021.05.25-00000',
+ '.kibana_shahzad_9',
+ '.kibana-felix-log-stream_8.0.0_001',
+ '.kibana_smith_alerts-observability-apm-000001',
+ '.ds-logs-endpoint.events.process-default-2021.05.26-000001',
+ '.kibana_dominiqueclarke54_8.0.0_001',
+ '.kibana-cmarcondes-19_8.0.0_001',
+ '.kibana_task_manager_cmarcondes-17_8.0.0_001',
+ '.kibana_task_manager_jrhodes_8.0.0_001',
+ '.kibana_task_manager_dominiqueclarke7_8',
+ '.kibana_dominiqueclarke55-alerts-8.0.0-000001',
+ '.ds-metrics-system.socket_summary-default-2021.05.12-000001',
+ '.kibana_task_manager_dominiqueclarke24_8.0.0_001',
+ '.kibana_custom_kbn-pr94906_8.0.0_001',
+ '.kibana_task_manager_cmarcondes-22_8.0.0_001',
+ '.kibana_dominiqueclarke49-event-log-8.0.0-000001',
+].sort();
+
+const kibanaNoTaskIndices = [
+ '.kibana_shahzad_4',
+ '.kibana_shahzad_3',
+ '.kibana_shahzad_2',
+ '.kibana_shahzad_1',
+ '.kibana_shahzad_9',
+ '.kibana-felix-log-stream_8.0.0_001',
+ '.kibana_smith_alerts-observability-apm-000001',
+ '.kibana_dominiqueclarke54_8.0.0_001',
+ '.kibana-cmarcondes-19_8.0.0_001',
+ '.kibana_dominiqueclarke55-alerts-8.0.0-000001',
+ '.kibana_custom_kbn-pr94906_8.0.0_001',
+ '.kibana_dominiqueclarke49-event-log-8.0.0-000001',
+].sort();
+
+describe('ES glob index patterns', () => {
+ it('should exclude system/internal indices', () => {
+ const validIndexPatterns = ESGlobPatterns.createRegExPatterns('-.*');
+ const validIndices = testIndices.filter((index) =>
+ ESGlobPatterns.isValid(index, validIndexPatterns)
+ );
+ expect(validIndices.sort()).toEqual(noSystemIndices);
+ });
+
+ it('should only show ".index" system indices', () => {
+ const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.*');
+ const validIndices = testIndices.filter((index) =>
+ ESGlobPatterns.isValid(index, validIndexPatterns)
+ );
+ expect(validIndices.sort()).toEqual(onlySystemIndices);
+ });
+
+ it('should only show ".kibana*" indices without _task_', () => {
+ const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.kibana*,-*_task_*');
+ const validIndices = testIndices.filter((index) =>
+ ESGlobPatterns.isValid(index, validIndexPatterns)
+ );
+ expect(validIndices.sort()).toEqual(kibanaNoTaskIndices);
+ });
+});
diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts
index db318d7962beb..a6a101bc42afa 100644
--- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts
+++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts
@@ -42,7 +42,7 @@ export class LargeShardSizeAlert extends BaseAlert {
id: ALERT_LARGE_SHARD_SIZE,
name: ALERT_DETAILS[ALERT_LARGE_SHARD_SIZE].label,
throttle: '12h',
- defaultParams: { indexPattern: '*', threshold: 55 },
+ defaultParams: { indexPattern: '-.*', threshold: 55 },
actionVariables: [
{
name: 'shardIndices',
diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts
index e1da45ab7d991..aab3f0101ef83 100644
--- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts
+++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts
@@ -120,11 +120,7 @@ export async function fetchIndexShardSize(
for (const indexBucket of indexBuckets) {
const shardIndex = indexBucket.key;
const topHit = indexBucket.hits?.hits?.hits[0] as TopHitType;
- if (
- !topHit ||
- shardIndex.charAt() === '.' ||
- !ESGlobPatterns.isValid(shardIndex, validIndexPatterns)
- ) {
+ if (!topHit || !ESGlobPatterns.isValid(shardIndex, validIndexPatterns)) {
continue;
}
const {
diff --git a/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx b/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx
index 14c4e0a7cb9af..d16fbf6f7cd14 100644
--- a/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx
+++ b/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx
@@ -5,8 +5,8 @@
* 2.0.
*/
-import { usePluginContext } from './use_plugin_context';
import { UI_SETTINGS } from '../../../../../src/plugins/data/public';
+import { useKibana } from '../../../../../src/plugins/kibana_react/public';
export { UI_SETTINGS };
@@ -14,6 +14,8 @@ type SettingKeys = keyof typeof UI_SETTINGS;
type SettingValues = typeof UI_SETTINGS[SettingKeys];
export function useKibanaUISettings(key: SettingValues): T {
- const { core } = usePluginContext();
- return core.uiSettings.get(key);
+ const {
+ services: { uiSettings },
+ } = useKibana();
+ return uiSettings!.get(key);
}
diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts
index a49d3461529c2..030046ce7bed9 100644
--- a/x-pack/plugins/observability/public/index.ts
+++ b/x-pack/plugins/observability/public/index.ts
@@ -57,6 +57,7 @@ export { useFetcher, FETCH_STATUS } from './hooks/use_fetcher';
export * from './typings';
export { useChartTheme } from './hooks/use_chart_theme';
+export { useBreadcrumbs } from './hooks/use_breadcrumbs';
export { useTheme } from './hooks/use_theme';
export { getApmTraceUrl } from './utils/get_apm_trace_url';
export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';
diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
index d8a4e1ce56bfa..1141437eae0ef 100644
--- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
+++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
@@ -195,10 +195,14 @@ export class HeadlessChromiumDriverFactory {
getBrowserLogger(page: puppeteer.Page, logger: LevelLogger): Rx.Observable {
const consoleMessages$ = Rx.fromEvent(page, 'console').pipe(
map((line) => {
+ const formatLine = () => `{ text: "${line.text()?.trim()}", url: ${line.location()?.url} }`;
+
if (line.type() === 'error') {
- logger.error(line.text(), ['headless-browser-console']);
+ logger.error(`Error in browser console: ${formatLine()}`, ['headless-browser-console']);
} else {
- logger.debug(line.text(), [`headless-browser-console:${line.type()}`]);
+ logger.debug(`Message in browser console: ${formatLine()}`, [
+ `headless-browser-console:${line.type()}`,
+ ]);
}
})
);
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts
index c084dd8ca7668..4367c0d90af79 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts
@@ -468,14 +468,23 @@ export type HostMetadata = Immutable<{
id: string;
status: HostPolicyResponseActionStatus;
name: string;
+ /** The endpoint integration policy revision number in kibana */
endpoint_policy_version: number;
version: number;
};
};
configuration: {
+ /**
+ * Shows whether the endpoint is set up to be isolated. (e.g. a user has isolated a host,
+ * and the endpoint successfully received that action and applied the setting)
+ */
isolation?: boolean;
};
state: {
+ /**
+ * Shows what the current state of the host is. This could differ from `Endpoint.configuration.isolation`
+ * in some cases, but normally they will match
+ */
isolation?: boolean;
};
};
diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap
index a2a36b3fe1d3b..84c8971e3d352 100644
--- a/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/common/components/header_page/__snapshots__/index.test.tsx.snap
@@ -6,6 +6,7 @@ exports[`HeaderPage it renders 1`] = `
>
- Test title
+
+ Test title
+
css`
+ display: block;
+
+ @media only screen and (min-width: ${theme.eui.euiBreakpoints.m}) {
+ max-width: 50%;
+ }
+ `}
`;
FlexItem.displayName = 'FlexItem';
@@ -112,7 +118,7 @@ const HeaderPageComponent: React.FC = ({
);
return (
-
+
{backOptions && (
diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx
index 5ec05273d16f3..471d539ea03f4 100644
--- a/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/header_page/title.tsx
@@ -11,6 +11,7 @@ import styled from 'styled-components';
import { DraggableArguments, BadgeOptions, TitleProp } from './types';
import { DefaultDraggable } from '../draggables';
+import { TruncatableText } from '../truncatable_text';
const StyledEuiBetaBadge = styled(EuiBetaBadge)`
vertical-align: middle;
@@ -33,7 +34,7 @@ const TitleComponent: React.FC = ({ draggableArguments, title, badgeOptio
{!draggableArguments ? (
- title
+ {title}
) : (
- Hiding in plain sight
-
+
+
+
+ Hiding in plain sight
+
+
+
`;
diff --git a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx
index f54d9e4ed0b88..3bf9cf60afeab 100644
--- a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.test.tsx
@@ -5,14 +5,14 @@
* 2.0.
*/
-import { mount, shallow } from 'enzyme';
+import { mount } from 'enzyme';
import React from 'react';
import { TruncatableText } from '.';
describe('TruncatableText', () => {
test('renders correctly against snapshot', () => {
- const wrapper = shallow({'Hiding in plain sight'});
+ const wrapper = mount({'Hiding in plain sight'});
expect(wrapper).toMatchSnapshot();
});
@@ -33,4 +33,11 @@ describe('TruncatableText', () => {
expect(wrapper).toHaveStyleRule('white-space', 'nowrap');
});
+
+ test('it can add tooltip', () => {
+ const testText = 'Some really really really really really long text.';
+ const wrapper = mount({testText});
+
+ expect(wrapper.find('EuiToolTip').text()).toEqual(testText);
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.ts b/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.ts
new file mode 100644
index 0000000000000..49ab0ac4defc3
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/truncatable_text/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 * from './truncatable_text';
diff --git a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.tsx b/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx
similarity index 54%
rename from x-pack/plugins/security_solution/public/common/components/truncatable_text/index.tsx
rename to x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx
index 2dd3c35f731e9..4c068675aa5a0 100644
--- a/x-pack/plugins/security_solution/public/common/components/truncatable_text/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/truncatable_text/truncatable_text.tsx
@@ -5,7 +5,9 @@
* 2.0.
*/
+import React from 'react';
import styled from 'styled-components';
+import { EuiToolTip } from '@elastic/eui';
/**
* Applies CSS styling to enable text to be truncated with an ellipsis.
@@ -14,7 +16,7 @@ import styled from 'styled-components';
* Note: Requires a parent container with a defined width or max-width.
*/
-export const TruncatableText = styled.span`
+const EllipsisText = styled.span`
&,
& * {
display: inline-block;
@@ -25,4 +27,19 @@ export const TruncatableText = styled.span`
white-space: nowrap;
}
`;
-TruncatableText.displayName = 'TruncatableText';
+EllipsisText.displayName = 'EllipsisText';
+
+interface Props {
+ tooltipContent?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export function TruncatableText({ tooltipContent, children, ...props }: Props) {
+ if (!tooltipContent) return {children};
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx
index ae2cc59de6abf..d96929ec183d8 100644
--- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx
+++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx
@@ -23,6 +23,7 @@ import { ExperimentalFeatures } from '../../../../common/experimental_features';
import { PLUGIN_ID } from '../../../../../fleet/common';
import { APP_ID } from '../../../../common/constants';
import { KibanaContextProvider } from '../../lib/kibana';
+import { MANAGEMENT_APP_ID } from '../../../management/common/constants';
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
@@ -156,6 +157,8 @@ const createCoreStartMock = (): ReturnType => {
return '/app/fleet';
case APP_ID:
return '/app/security';
+ case MANAGEMENT_APP_ID:
+ return '/app/security/administration';
default:
return `${appId} not mocked!`;
}
diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts
index 9d12efca19aed..2df16fc1e21b0 100644
--- a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts
+++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts
@@ -13,7 +13,7 @@ import type {
HttpHandler,
HttpStart,
} from 'kibana/public';
-import { extend } from 'lodash';
+import { merge } from 'lodash';
import { act } from '@testing-library/react';
class ApiRouteNotMocked extends Error {}
@@ -159,6 +159,11 @@ export const httpHandlerMockFactory = ['responseProvider'] = mocks.reduce(
(providers, routeMock) => {
// FIXME: find a way to remove the ignore below. May need to limit the calling signature of `RouteMock['handler']`
@@ -195,7 +200,7 @@ export const httpHandlerMockFactory = {
const path = isHttpFetchOptionsWithPath(args[0]) ? args[0].path : args[0];
- const routeMock = methodMocks.find((handler) => handler.path === path);
+ const routeMock = methodMocks.find((handler) => pathMatchesPattern(handler.path, path));
if (routeMock) {
markApiCallAsHandled(responseProvider[routeMock.id].mockDelay);
@@ -211,6 +216,9 @@ export const httpHandlerMockFactory =