= {
+ name: cloudSecurityPostureRuleTemplateSavedObjectType,
+ hidden: false,
+ management: {
+ importableAndExportable: true,
+ visibleInManagement: true,
+ },
+ namespaceType: 'agnostic',
+ mappings: ruleTemplateAssetSavedObjectMappings,
+};
diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts
similarity index 90%
rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts
rename to x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts
index fcff7449fb3f5..4b323c127c0e6 100644
--- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts
+++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts
@@ -6,15 +6,12 @@
*/
import { i18n } from '@kbn/i18n';
-import type {
- SavedObjectsType,
- SavedObjectsValidationMap,
-} from '../../../../../../src/core/server';
+import type { SavedObjectsType, SavedObjectsValidationMap } from '../../../../../src/core/server';
import {
type CspRuleSchema,
cspRuleSchema,
cspRuleAssetSavedObjectType,
-} from '../../../common/schemas/csp_rule';
+} from '../../common/schemas/csp_rule';
const validationMap: SavedObjectsValidationMap = {
'1.0.0': cspRuleSchema,
diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts
similarity index 83%
rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts
rename to x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts
index 1cb08ddc1be1a..71e7697296acb 100644
--- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts
+++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts
@@ -6,8 +6,8 @@
*/
import type { ISavedObjectsRepository } from 'src/core/server';
-import { CIS_BENCHMARK_1_4_1_RULES } from './rules';
-import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule';
+import { CIS_BENCHMARK_1_4_1_RULES } from './cis_1_4_1/rules';
+import { cspRuleAssetSavedObjectType } from '../../common/schemas/csp_rule';
export const initializeCspRules = async (client: ISavedObjectsRepository) => {
const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 });
diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts
index 4e70027013df8..9fe602424321c 100644
--- a/x-pack/plugins/cloud_security_posture/server/types.ts
+++ b/x-pack/plugins/cloud_security_posture/server/types.ts
@@ -10,7 +10,14 @@ import type {
PluginStart as DataPluginStart,
} from '../../../../src/plugins/data/server';
-import type { FleetStartContract } from '../../fleet/server';
+import type {
+ RouteMethod,
+ KibanaResponseFactory,
+ RequestHandler,
+ IRouter,
+} from '../../../../src/core/server';
+
+import type { FleetStartContract, FleetRequestHandlerContext } from '../../fleet/server';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface CspServerPluginSetup {}
@@ -29,3 +36,23 @@ export interface CspServerPluginStartDeps {
data: DataPluginStart;
fleet: FleetStartContract;
}
+
+export type CspRequestHandlerContext = FleetRequestHandlerContext;
+
+/**
+ * Convenience type for request handlers in CSP that includes the CspRequestHandlerContext type
+ * @internal
+ */
+export type CspRequestHandler<
+ P = unknown,
+ Q = unknown,
+ B = unknown,
+ Method extends RouteMethod = any,
+ ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory
+> = RequestHandler;
+
+/**
+ * Convenience type for routers in Csp that includes the CspRequestHandlerContext type
+ * @internal
+ */
+export type CspRouter = IRouter;
diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts
index abfe089e82a38..aa8c2c0e3aa00 100644
--- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts
+++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts
@@ -17,6 +17,7 @@ export async function getSearchStatus(
asyncId: string
): Promise> {
// TODO: Handle strategies other than the default one
+ // https://github.com/elastic/kibana/issues/127880
try {
// @ts-expect-error start_time_in_millis: EpochMillis is string | number
const apiResponse: TransportResult = await client.asyncSearch.status(
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts
index 25342f24cc872..9c06527162b81 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts
@@ -9,7 +9,12 @@ import { Schema } from '../../../../shared/schema/types';
import { Fields } from './types';
-export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields: Fields) => {
+export const buildSearchUIConfig = (
+ apiConnector: object,
+ schema: Schema,
+ fields: Fields,
+ initialState = { sortDirection: 'desc', sortField: 'id' }
+) => {
const facets = fields.filterFields.reduce(
(facetsConfig, fieldName) => ({
...facetsConfig,
@@ -22,10 +27,7 @@ export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields
alwaysSearchOnInitialLoad: true,
apiConnector,
trackUrlState: false,
- initialState: {
- sortDirection: 'desc',
- sortField: 'id',
- },
+ initialState,
searchQuery: {
disjunctiveFacets: fields.filterFields,
facets,
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx
index ed2a1ed54f06d..52e0acbc81520 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx
@@ -31,25 +31,39 @@ import { SearchExperienceContent } from './search_experience_content';
import { Fields, SortOption } from './types';
import { SearchBoxView, SortingView, MultiCheckboxFacetsView } from './views';
-const RECENTLY_UPLOADED = i18n.translate(
- 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded',
+const DOCUMENT_ID = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.documentId',
{
- defaultMessage: 'Recently Uploaded',
+ defaultMessage: 'Document ID',
}
);
+
+const RELEVANCE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.relevance',
+ { defaultMessage: 'Relevance' }
+);
+
const DEFAULT_SORT_OPTIONS: SortOption[] = [
{
- name: DESCENDING(RECENTLY_UPLOADED),
+ name: DESCENDING(DOCUMENT_ID),
value: 'id',
direction: 'desc',
},
{
- name: ASCENDING(RECENTLY_UPLOADED),
+ name: ASCENDING(DOCUMENT_ID),
value: 'id',
direction: 'asc',
},
];
+const RELEVANCE_SORT_OPTIONS: SortOption[] = [
+ {
+ name: RELEVANCE,
+ value: '_score',
+ direction: 'desc',
+ },
+];
+
export const SearchExperience: React.FC = () => {
const { engine } = useValues(EngineLogic);
const { http } = useValues(HttpLogic);
@@ -66,8 +80,10 @@ export const SearchExperience: React.FC = () => {
sortFields: [],
}
);
+ const sortOptions =
+ engine.type === 'elasticsearch' ? RELEVANCE_SORT_OPTIONS : DEFAULT_SORT_OPTIONS;
- const sortingOptions = buildSortOptions(fields, DEFAULT_SORT_OPTIONS);
+ const sortingOptions = buildSortOptions(fields, sortOptions);
const connector = new AppSearchAPIConnector({
cacheResponses: false,
@@ -78,7 +94,17 @@ export const SearchExperience: React.FC = () => {
},
});
- const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {}, fields);
+ const initialState = {
+ sortField: engine.type === 'elasticsearch' ? '_score' : 'id',
+ sortDirection: 'desc',
+ };
+
+ const searchProviderConfig = buildSearchUIConfig(
+ connector,
+ engine.schema || {},
+ fields,
+ initialState
+ );
return (
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
index 6faa749f95864..acdeed4854ecd 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts
@@ -12,6 +12,7 @@ export enum EngineTypes {
default = 'default',
indexed = 'indexed',
meta = 'meta',
+ elasticsearch = 'elasticsearch',
}
export interface Engine {
name: string;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx
similarity index 88%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx
index 6a93291a28cb3..4917877c0ec30 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx
@@ -5,9 +5,9 @@
* 2.0.
*/
-import '../../../../../__mocks__/shallow_useeffect.mock';
-import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
-import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
+import '../../../../../../__mocks__/shallow_useeffect.mock';
+import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic';
+import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock';
import React from 'react';
@@ -18,8 +18,8 @@ import { EuiSteps } from '@elastic/eui';
import {
WorkplaceSearchPageTemplate,
PersonalDashboardLayout,
-} from '../../../../components/layout';
-import { staticSourceData } from '../../source_data';
+} from '../../../../../components/layout';
+import { staticSourceData } from '../../../source_data';
import { ExternalConnectorConfig } from './external_connector_config';
import { ExternalConnectorFormFields } from './external_connector_form_fields';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx
similarity index 81%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx
index 637be68929ac0..002cafa2e3229 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx
@@ -21,17 +21,20 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { AppLogic } from '../../../../app_logic';
+import { AppLogic } from '../../../../../app_logic';
import {
PersonalDashboardLayout,
WorkplaceSearchPageTemplate,
-} from '../../../../components/layout';
-import { NAV, REMOVE_BUTTON } from '../../../../constants';
-import { SourceDataItem } from '../../../../types';
+} from '../../../../../components/layout';
+import { NAV, REMOVE_BUTTON } from '../../../../../constants';
+import { SourceDataItem } from '../../../../../types';
-import { AddSourceHeader } from './add_source_header';
-import { ConfigDocsLinks } from './config_docs_links';
-import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants';
+import { staticExternalSourceData } from '../../../source_data';
+
+import { AddSourceHeader } from './../add_source_header';
+import { ConfigDocsLinks } from './../config_docs_links';
+import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './../constants';
+import { ExternalConnectorDocumentation } from './external_connector_documentation';
import { ExternalConnectorFormFields } from './external_connector_form_fields';
import { ExternalConnectorLogic } from './external_connector_logic';
@@ -69,10 +72,14 @@ export const ExternalConnectorConfig: React.FC
= ({
const { name, categories } = sourceConfigData;
const {
- configuration: { documentationUrl, applicationLinkTitle, applicationPortalUrl },
+ configuration: { applicationLinkTitle, applicationPortalUrl },
} = sourceData;
const { isOrganization } = useValues(AppLogic);
+ const {
+ configuration: { documentationUrl },
+ } = staticExternalSourceData;
+
const saveButton = (
{OAUTH_SAVE_CONFIG_BUTTON}
@@ -135,6 +142,8 @@ export const ExternalConnectorConfig: React.FC = ({
{header}
+
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx
new file mode 100644
index 0000000000000..13b8967637ee1
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx
@@ -0,0 +1,24 @@
+/*
+ * 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 { shallow } from 'enzyme';
+
+import { EuiText } from '@elastic/eui';
+
+import { ExternalConnectorDocumentation } from './external_connector_documentation';
+
+describe('ExternalDocumentation', () => {
+ it('renders', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(EuiText)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx
new file mode 100644
index 0000000000000..437bf6f683198
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx
@@ -0,0 +1,68 @@
+/*
+ * 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 { EuiText, EuiLink } from '@elastic/eui';
+
+import { FormattedMessage } from '@kbn/i18n-react';
+
+interface ExternalConnectorDocumentationProps {
+ name: string;
+ documentationUrl: string;
+}
+
+export const ExternalConnectorDocumentation: React.FC = ({
+ name,
+ documentationUrl,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx
similarity index 95%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx
index 931a2f3517fbb..45a7dd122eabf 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx
@@ -5,8 +5,8 @@
* 2.0.
*/
-import '../../../../../__mocks__/shallow_useeffect.mock';
-import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
+import '../../../../../../__mocks__/shallow_useeffect.mock';
+import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic';
import React from 'react';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx
similarity index 100%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts
similarity index 97%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts
index 38bf74052541c..0e9ad386a353d 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts
@@ -10,18 +10,19 @@ import {
mockFlashMessageHelpers,
mockHttpValues,
mockKibanaValues,
-} from '../../../../../__mocks__/kea_logic';
-import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
+} from '../../../../../../__mocks__/kea_logic';
+import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock';
import { nextTick } from '@kbn/test-jest-helpers';
-import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers';
+import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers';
-jest.mock('../../../../app_logic', () => ({
+jest.mock('../../../../../app_logic', () => ({
AppLogic: { values: { isOrganization: true } },
}));
-import { AddSourceLogic, SourceConfigData } from './add_source_logic';
+import { AddSourceLogic, SourceConfigData } from '../add_source_logic';
+
import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic';
describe('ExternalConnectorLogic', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts
similarity index 94%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts
index 1f7edf0d8e2a9..3bf96a31dd8c5 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts
@@ -13,14 +13,14 @@ import {
flashAPIErrors,
flashSuccessToast,
clearFlashMessages,
-} from '../../../../../shared/flash_messages';
-import { HttpLogic } from '../../../../../shared/http';
-import { KibanaLogic } from '../../../../../shared/kibana';
-import { AppLogic } from '../../../../app_logic';
+} from '../../../../../../shared/flash_messages';
+import { HttpLogic } from '../../../../../../shared/http';
+import { KibanaLogic } from '../../../../../../shared/kibana';
+import { AppLogic } from '../../../../../app_logic';
-import { getAddPath, getSourcesPath } from '../../../../routes';
+import { getAddPath, getSourcesPath } from '../../../../../routes';
-import { AddSourceLogic, SourceConfigData } from './add_source_logic';
+import { AddSourceLogic, SourceConfigData } from '../add_source_logic';
export interface ExternalConnectorActions {
fetchExternalSource: () => true;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts
new file mode 100644
index 0000000000000..7f2871a9f5c75
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts
@@ -0,0 +1,11 @@
+/*
+ * 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 { ExternalConnectorConfig } from './external_connector_config';
+export { ExternalConnectorFormFields } from './external_connector_form_fields';
+export { ExternalConnectorLogic } from './external_connector_logic';
+export { ExternalConnectorDocumentation } from './external_connector_documentation';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts
index 21246defbb863..6b335b1f7ffe4 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts
@@ -29,6 +29,7 @@ import { FeatureIds } from '../../../../types';
import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants';
import { SourcesLogic } from '../../sources_logic';
+import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic';
import {
AddSourceLogic,
AddSourceSteps,
@@ -38,7 +39,6 @@ import {
AddSourceValues,
AddSourceProps,
} from './add_source_logic';
-import { ExternalConnectorLogic } from './external_connector_logic';
describe('AddSourceLogic', () => {
const { mount } = new LogicMounter(AddSourceLogic);
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts
index 8693cffc17e21..c621e0ee16bd5 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts
@@ -25,7 +25,10 @@ import { SourceDataItem } from '../../../../types';
import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants';
import { SourcesLogic } from '../../sources_logic';
-import { ExternalConnectorLogic, isValidExternalUrl } from './external_connector_logic';
+import {
+ ExternalConnectorLogic,
+ isValidExternalUrl,
+} from './add_external_connector/external_connector_logic';
export interface AddSourceProps {
sourceData: SourceDataItem;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx
index 9a5673451cd1a..8d8311d2a0a6f 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx
@@ -30,7 +30,7 @@ interface CardProps {
description: string;
buttonText: string;
onClick: () => void;
- betaBadgeLabel?: string;
+ badgeLabel?: string;
}
export const ConfigurationChoice: React.FC = ({
@@ -75,14 +75,14 @@ export const ConfigurationChoice: React.FC = ({
description,
buttonText,
onClick,
- betaBadgeLabel,
+ badgeLabel,
}: CardProps) => (
{buttonText}
@@ -96,13 +96,14 @@ export const ConfigurationChoice: React.FC = ({
title: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title',
{
- defaultMessage: 'Default connector',
+ defaultMessage: 'Connector',
}
),
description: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description',
{
- defaultMessage: 'Use our out-of-the-box connector to get started quickly.',
+ defaultMessage:
+ 'Use this connector to get started quickly without deploying additional infrastructure.',
}
),
buttonText: i18n.translate(
@@ -111,6 +112,12 @@ export const ConfigurationChoice: React.FC = ({
defaultMessage: 'Connect',
}
),
+ badgeLabel: i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.recommendedLabel',
+ {
+ defaultMessage: 'Recommended',
+ }
+ ),
onClick: goToInternal,
};
@@ -118,13 +125,14 @@ export const ConfigurationChoice: React.FC = ({
title: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title',
{
- defaultMessage: 'Custom connector',
+ defaultMessage: 'Connector Package',
}
),
description: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description',
{
- defaultMessage: 'Set up a custom connector for more configurability and control.',
+ defaultMessage:
+ 'Deploy this connector package on self-managed infrastructure for advanced use cases.',
}
),
buttonText: i18n.translate(
@@ -134,7 +142,7 @@ export const ConfigurationChoice: React.FC = ({
}
),
onClick: goToExternal,
- betaBadgeLabel: i18n.translate(
+ badgeLabel: i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel',
{
defaultMessage: 'Beta',
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx
index 5c234be583b9d..3e35c608fcee8 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx
@@ -18,8 +18,8 @@ import { EuiSteps, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { ApiKey } from '../../../../components/shared/api_key';
import { staticSourceData } from '../../source_data';
+import { ExternalConnectorFormFields } from './add_external_connector';
import { ConfigDocsLinks } from './config_docs_links';
-import { ExternalConnectorFormFields } from './external_connector_form_fields';
import { SaveConfig } from './save_config';
describe('SaveConfig', () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx
index d56efcdab95d6..eb887a9f8cc42 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx
@@ -35,10 +35,11 @@ import {
} from '../../../../constants';
import { Configuration } from '../../../../types';
+import { ExternalConnectorFormFields } from './add_external_connector';
+import { ExternalConnectorDocumentation } from './add_external_connector';
import { AddSourceLogic } from './add_source_logic';
import { ConfigDocsLinks } from './config_docs_links';
import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants';
-import { ExternalConnectorFormFields } from './external_connector_form_fields';
interface SaveConfigProps {
header: React.ReactNode;
@@ -224,6 +225,12 @@ export const SaveConfig: React.FC = ({
<>
{header}
+ {serviceType === 'external' && (
+ <>
+
+
+ >
+ )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx
index 361eccbe8da38..5b1e4d97ef4cd 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx
@@ -12,6 +12,35 @@ import { docLinks } from '../../../shared/doc_links';
import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants';
import { FeatureIds, SourceDataItem } from '../../types';
+export const staticExternalSourceData: SourceDataItem = {
+ name: SOURCE_NAMES.SHAREPOINT,
+ iconName: SOURCE_NAMES.SHAREPOINT,
+ serviceType: 'external',
+ configuration: {
+ isPublicKey: false,
+ hasOauthRedirect: true,
+ needsBaseUrl: false,
+ documentationUrl: docLinks.workplaceSearchExternalSharePointOnline,
+ applicationPortalUrl: 'https://portal.azure.com/',
+ },
+ objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES],
+ features: {
+ basicOrgContext: [
+ FeatureIds.SyncFrequency,
+ FeatureIds.SyncedItems,
+ FeatureIds.GlobalAccessPermissions,
+ ],
+ basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions],
+ platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
+ platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
+ },
+ accountContextOnly: false,
+ internalConnectorAvailable: true,
+ externalConnectorAvailable: false,
+ customConnectorAvailable: false,
+ isBeta: true,
+};
+
export const staticSourceData: SourceDataItem[] = [
{
name: SOURCE_NAMES.BOX,
@@ -502,39 +531,7 @@ export const staticSourceData: SourceDataItem[] = [
internalConnectorAvailable: true,
externalConnectorAvailable: true,
},
- // TODO: temporary hack until backend sends us stuff
- {
- name: SOURCE_NAMES.SHAREPOINT,
- iconName: SOURCE_NAMES.SHAREPOINT,
- serviceType: 'external',
- configuration: {
- isPublicKey: false,
- hasOauthRedirect: true,
- needsBaseUrl: false,
- documentationUrl: docLinks.workplaceSearchExternalSharePointOnline,
- applicationPortalUrl: 'https://portal.azure.com/',
- },
- objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES],
- features: {
- basicOrgContext: [
- FeatureIds.SyncFrequency,
- FeatureIds.SyncedItems,
- FeatureIds.GlobalAccessPermissions,
- ],
- basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions],
- platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems],
- platinumPrivateContext: [
- FeatureIds.Private,
- FeatureIds.SyncFrequency,
- FeatureIds.SyncedItems,
- ],
- },
- accountContextOnly: false,
- internalConnectorAvailable: true,
- externalConnectorAvailable: false,
- customConnectorAvailable: false,
- isBeta: true,
- },
+ staticExternalSourceData,
{
name: SOURCE_NAMES.SHAREPOINT_SERVER,
iconName: SOURCE_NAMES.SHAREPOINT_SERVER,
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx
index e735119f687cc..19af955f8780c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx
@@ -30,8 +30,8 @@ import { hasMultipleConnectorOptions } from '../../utils';
import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source';
import { AddCustomSource } from './components/add_source/add_custom_source';
+import { ExternalConnectorConfig } from './components/add_source/add_external_connector';
import { ConfigurationChoice } from './components/add_source/configuration_choice';
-import { ExternalConnectorConfig } from './components/add_source/external_connector_config';
import { OrganizationSources } from './organization_sources';
import { PrivateSources } from './private_sources';
import { staticCustomSourceData, staticSourceData as sources } from './source_data';
diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts
index 6f48b15158f8d..0b4f30a137192 100644
--- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts
+++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts
@@ -252,6 +252,7 @@ export const item: GetInfoResponse['item'] = {
lens: [],
map: [],
security_rule: [],
+ csp_rule_template: [],
tag: [],
},
elasticsearch: {
diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts
index 6b766c2d126df..5c08120084cb9 100644
--- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts
+++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts
@@ -105,6 +105,7 @@ export const item: GetInfoResponse['item'] = {
lens: [],
ml_module: [],
security_rule: [],
+ csp_rule_template: [],
tag: [],
},
elasticsearch: {
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json
index b355a62fbf241..e9bb796626f58 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.json
+++ b/x-pack/plugins/fleet/common/openapi/bundled.json
@@ -3585,7 +3585,8 @@
"map",
"lens",
"ml-module",
- "security-rule"
+ "security-rule",
+ "csp-rule-template"
]
},
"elasticsearch_asset_type": {
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml
index 9a352f94e8252..f7941f863c120 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.yaml
+++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml
@@ -2238,6 +2238,7 @@ components:
- lens
- ml-module
- security-rule
+ - csp_rule_template
elasticsearch_asset_type:
title: Elasticsearch asset type
type: string
diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml
index 4ec82e7507166..1a7d29311e4fe 100644
--- a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml
+++ b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml
@@ -9,3 +9,4 @@ enum:
- lens
- ml-module
- security-rule
+ - csp_rule_template
diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts
index 0cf8c3e88f568..ee47c3faa305a 100644
--- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts
+++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts
@@ -25,6 +25,7 @@ describe('Fleet - packageToPackagePolicy', () => {
path: '',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts
index dcff9f503bfe0..93be8684698ca 100644
--- a/x-pack/plugins/fleet/common/types/models/epm.ts
+++ b/x-pack/plugins/fleet/common/types/models/epm.ts
@@ -72,6 +72,7 @@ export enum KibanaAssetType {
map = 'map',
lens = 'lens',
securityRule = 'security_rule',
+ cloudSecurityPostureRuleTemplate = 'csp_rule_template',
mlModule = 'ml_module',
tag = 'tag',
}
@@ -88,6 +89,7 @@ export enum KibanaSavedObjectType {
lens = 'lens',
mlModule = 'ml-module',
securityRule = 'security-rule',
+ cloudSecurityPostureRuleTemplate = 'csp-rule-template',
tag = 'tag',
}
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx
index 1ba7f09d0333d..9fdcc0f73297f 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx
@@ -309,6 +309,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent =
isInvalid={Boolean(touchedFields.data_output_id && validation.data_output_id)}
>
=
isInvalid={Boolean(touchedFields.monitoring_output_id && validation.monitoring_output_id)}
>
{
= {
tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', {
defaultMessage: 'Tag',
}),
+ csp_rule_template: i18n.translate(
+ 'xpack.fleet.epm.assetTitles.cloudSecurityPostureRuleTemplate',
+ {
+ defaultMessage: 'Cloud Security Posture rule template',
+ }
+ ),
};
export const ServiceTitleMap: Record = {
@@ -89,6 +95,7 @@ export const AssetIcons: Record = {
map: 'emsApp',
lens: 'lensApp',
security_rule: 'securityApp',
+ csp_rule_template: 'securityApp', // TODO ICON
ml_module: 'mlApp',
tag: 'tagApp',
};
diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx
index d002a743e77bc..dbd1c71da3d1b 100644
--- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx
+++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx
@@ -144,9 +144,13 @@ export function Detail() {
// Refresh package info when status change
const [oldPackageInstallStatus, setOldPackageStatus] = useState(packageInstallStatus);
+
useEffect(() => {
+ if (packageInstallStatus === 'not_installed') {
+ setOldPackageStatus(packageInstallStatus);
+ }
if (oldPackageInstallStatus === 'not_installed' && packageInstallStatus === 'installed') {
- setOldPackageStatus(oldPackageInstallStatus);
+ setOldPackageStatus(packageInstallStatus);
refreshPackageInfo();
}
}, [packageInstallStatus, oldPackageInstallStatus, refreshPackageInfo]);
diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
index 4016d4ea690c4..dbf1db0f68f28 100644
--- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
+++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts
@@ -142,13 +142,15 @@ export const updateAgentPolicyHandler: RequestHandler<
const soClient = context.core.savedObjects.client;
const esClient = context.core.elasticsearch.client.asInternalUser;
const user = await appContextService.getSecurity()?.authc.getCurrentUser(request);
+ const { force, ...data } = request.body;
try {
const agentPolicy = await agentPolicyService.update(
soClient,
esClient,
request.params.agentPolicyId,
- request.body,
+ data,
{
+ force,
user: user || undefined,
}
);
diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts
index 170942d59061f..c34104e491da8 100644
--- a/x-pack/plugins/fleet/server/services/agent_policy.ts
+++ b/x-pack/plugins/fleet/server/services/agent_policy.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { uniq, omit } from 'lodash';
+import { uniq, omit, isEqual } from 'lodash';
import uuid from 'uuid/v4';
import uuidv5 from 'uuid/v5';
import { safeDump } from 'js-yaml';
@@ -68,6 +68,8 @@ import { validateOutputForPolicy } from './agent_policies';
const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE;
+const KEY_EDITABLE_FOR_MANAGED_POLICIES = ['namespace'];
+
class AgentPolicyService {
private triggerAgentPolicyUpdatedEvent = async (
soClient: SavedObjectsClientContract,
@@ -344,7 +346,7 @@ class AgentPolicyService {
esClient: ElasticsearchClient,
id: string,
agentPolicy: Partial,
- options?: { user?: AuthenticatedUser }
+ options?: { user?: AuthenticatedUser; force?: boolean }
): Promise {
if (agentPolicy.name) {
await this.requireUniqueName(soClient, {
@@ -352,6 +354,23 @@ class AgentPolicyService {
name: agentPolicy.name,
});
}
+
+ const existingAgentPolicy = await this.get(soClient, id, true);
+
+ if (!existingAgentPolicy) {
+ throw new Error('Agent policy not found');
+ }
+
+ if (existingAgentPolicy.is_managed && !options?.force) {
+ Object.entries(agentPolicy)
+ .filter(([key]) => !KEY_EDITABLE_FOR_MANAGED_POLICIES.includes(key))
+ .forEach(([key, val]) => {
+ if (!isEqual(existingAgentPolicy[key as keyof AgentPolicy], val)) {
+ throw new HostedAgentPolicyRestrictionRelatedError(`Cannot update ${key}`);
+ }
+ });
+ }
+
return this._update(soClient, esClient, id, agentPolicy, options?.user);
}
diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts
index 86edf1c5e4064..77ce3779f2319 100644
--- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts
+++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts
@@ -157,6 +157,27 @@ describe('EPM template', () => {
expect(mappings).toEqual(longWithIndexFalseMapping);
});
+ it('tests processing keyword field with doc_values false', () => {
+ const keywordWithIndexFalseYml = `
+- name: keywordIndexFalse
+ type: keyword
+ doc_values: false
+`;
+ const keywordWithIndexFalseMapping = {
+ properties: {
+ keywordIndexFalse: {
+ ignore_above: 1024,
+ type: 'keyword',
+ doc_values: false,
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(keywordWithIndexFalseYml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(mappings).toEqual(keywordWithIndexFalseMapping);
+ });
+
it('tests processing text field with multi fields', () => {
const textWithMultiFieldsLiteralYml = `
- name: textWithMultiFields
@@ -378,6 +399,34 @@ describe('EPM template', () => {
expect(mappings).toEqual(keywordWithMultiFieldsMapping);
});
+ it('tests processing wildcard field with multi fields with match_only_text type', () => {
+ const wildcardWithMultiFieldsLiteralYml = `
+- name: wildcardWithMultiFields
+ type: wildcard
+ multi_fields:
+ - name: text
+ type: match_only_text
+`;
+
+ const wildcardWithMultiFieldsMapping = {
+ properties: {
+ wildcardWithMultiFields: {
+ ignore_above: 1024,
+ type: 'wildcard',
+ fields: {
+ text: {
+ type: 'match_only_text',
+ },
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(wildcardWithMultiFieldsLiteralYml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(mappings).toEqual(wildcardWithMultiFieldsMapping);
+ });
+
it('tests processing object field with no other attributes', () => {
const objectFieldLiteralYml = `
- name: objectField
diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
index 21c7351b31384..909b593649fcd 100644
--- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
+++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
@@ -244,9 +244,8 @@ function generateMultiFields(fields: Fields): MultiFields {
multiFields[f.name] = { ...generateKeywordMapping(f), type: f.type };
break;
case 'long':
- multiFields[f.name] = { type: f.type };
- break;
case 'double':
+ case 'match_only_text':
multiFields[f.name] = { type: f.type };
break;
}
@@ -302,7 +301,7 @@ function getDefaultProperties(field: Field): Properties {
if (field.index !== undefined) {
properties.index = field.index;
}
- if (field.doc_values) {
+ if (field.doc_values !== undefined) {
properties.doc_values = field.doc_values;
}
if (field.copy_to) {
diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
index e76e44476df03..491e4e27825c4 100644
--- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
+++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
@@ -52,6 +52,8 @@ const KibanaSavedObjectTypeMapping: Record {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -170,6 +171,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -262,6 +264,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
@@ -386,6 +389,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => {
status: 'not_installed',
assets: {
kibana: {
+ csp_rule_template: [],
dashboard: [],
visualization: [],
search: [],
diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts
index 27919d7bf1011..862b589896793 100644
--- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts
+++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts
@@ -689,7 +689,10 @@ describe('policy preconfiguration', () => {
name: 'Renamed Test policy',
description: 'Renamed Test policy description',
unenroll_timeout: 999,
- })
+ }),
+ {
+ force: true,
+ }
);
expect(policies.length).toEqual(1);
expect(policies[0].id).toBe('test-id');
diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts
index 6f8c8bbc6a20d..c11925fa8f2f3 100644
--- a/x-pack/plugins/fleet/server/services/preconfiguration.ts
+++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts
@@ -159,7 +159,10 @@ export async function ensurePreconfiguredPackagesAndPolicies(
soClient,
esClient,
String(preconfiguredAgentPolicy.id),
- fields
+ fields,
+ {
+ force: true,
+ }
);
return { created, policy: updatedPolicy };
}
@@ -254,7 +257,15 @@ export async function ensurePreconfiguredPackagesAndPolicies(
// Add the is_managed flag after configuring package policies to avoid errors
if (shouldAddIsManagedFlag) {
- await agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true });
+ await agentPolicyService.update(
+ soClient,
+ esClient,
+ policy!.id,
+ { is_managed: true },
+ {
+ force: true,
+ }
+ );
}
}
}
diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts
index 64d142f150bfd..042129e1e0914 100644
--- a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts
+++ b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts
@@ -32,7 +32,9 @@ export const CreateAgentPolicyRequestSchema = {
export const UpdateAgentPolicyRequestSchema = {
...GetOneAgentPolicyRequestSchema,
- body: NewAgentPolicySchema,
+ body: NewAgentPolicySchema.extends({
+ force: schema.maybe(schema.boolean()),
+ }),
};
export const CopyAgentPolicyRequestSchema = {
diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap
index 25930c07fcd8b..802d684a8a261 100644
--- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap
+++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap
@@ -130,7 +130,6 @@ exports[`extend index management ilm summary extension should return extension w
"step": "ERROR",
"step_info": Object {
"reason": "setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined",
- "stack_trace": "fakestacktrace",
"type": "illegal_argument_exception",
},
"step_time_millis": 1544187776208,
@@ -332,81 +331,6 @@ exports[`extend index management ilm summary extension should return extension w
illegal_argument_exception
:
setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined
-
-
-
-
-
-
- }
- closePopover={[Function]}
- display="inlineBlock"
- hasArrow={true}
- id="stackPopover"
- isOpen={false}
- ownFocus={true}
- panelPaddingSize="m"
- >
-
-
-
-
-
-
-
-
diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx
index eaebd6381d984..544aad4c52088 100644
--- a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx
+++ b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx
@@ -113,7 +113,6 @@ const indexWithLifecycleError = {
step_info: {
type: 'illegal_argument_exception',
reason: 'setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined',
- stack_trace: 'fakestacktrace',
},
phase_execution: {
policy: 'testy',
diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts
index 085179f14913d..ad1b1b2b28880 100644
--- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts
+++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts
@@ -229,7 +229,6 @@ export interface IndexLifecyclePolicy {
step?: string;
step_info?: {
reason?: string;
- stack_trace?: string;
type?: string;
message?: string;
};
diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx
index 4a34a4eb11ea4..fa148a5ba960b 100644
--- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx
+++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx
@@ -10,7 +10,6 @@ import moment from 'moment-timezone';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
- EuiButtonEmpty,
EuiCallOut,
EuiCodeBlock,
EuiFlexGroup,
@@ -108,31 +107,6 @@ export class IndexLifecycleSummary extends Component {
closePhaseExecutionPopover = () => {
this.setState({ showPhaseExecutionPopover: false });
};
- renderStackPopoverButton(ilm: IndexLifecyclePolicy) {
- if (!ilm.step_info!.stack_trace) {
- return null;
- }
- const button = (
-
-
-
- );
- return (
-
-
-
{ilm.step_info!.stack_trace}
-
-
- );
- }
renderPhaseExecutionPopoverButton(ilm: IndexLifecyclePolicy) {
const button = (
@@ -257,12 +231,10 @@ export class IndexLifecycleSummary extends Component {
iconType="cross"
>
{ilm.step_info.type}: {ilm.step_info.reason}
-
- {this.renderStackPopoverButton(ilm)}
>
) : null}
- {ilm.step_info && ilm.step_info!.message && !ilm.step_info!.stack_trace ? (
+ {ilm.step_info && ilm.step_info!.message ? (
<>
| any[];
+type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST';
export interface ResponseError {
statusCode: number;
@@ -17,139 +18,105 @@ export interface ResponseError {
}
// Register helpers to mock HTTP Requests
-const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
- const setLoadTemplatesResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/index_templates`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadIndicesResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/indices`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setReloadIndicesResponse = (response: HttpResponse = []) => {
- server.respondWith('POST', `${API_BASE_PATH}/indices/reload`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadDataStreamsResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/data_streams`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadDataStreamResponse = (response: HttpResponse = []) => {
- server.respondWith('GET', `${API_BASE_PATH}/data_streams/:id`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setDeleteDataStreamResponse = (response: HttpResponse = []) => {
- server.respondWith('POST', `${API_BASE_PATH}/delete_data_streams`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setDeleteTemplateResponse = (response: HttpResponse = []) => {
- server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
- };
-
- const setLoadTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
-
- server.respondWith('GET', `${API_BASE_PATH}/index_templates/:id`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
-
- const setCreateTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.body.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
-
- server.respondWith('POST', `${API_BASE_PATH}/index_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
-
- const setUpdateTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
-
- server.respondWith('PUT', `${API_BASE_PATH}/index_templates/:name`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
-
- const setUpdateIndexSettingsResponse = (response?: HttpResponse, error?: ResponseError) => {
- const status = error ? error.statusCode || 400 : 200;
- const body = error ?? response;
-
- server.respondWith('PUT', `${API_BASE_PATH}/settings/:name`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
-
- const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
-
- server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
-
- const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
-
- server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
-
- const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: any) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
-
- server.respondWith('GET', `${API_BASE_PATH}/nodes/plugins`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
+const registerHttpRequestMockHelpers = (
+ httpSetup: ReturnType
+) => {
+ const mockResponses = new Map>>(
+ ['GET', 'PUT', 'DELETE', 'POST'].map(
+ (method) => [method, new Map()] as [HttpMethod, Map>]
+ )
+ );
+
+ const mockMethodImplementation = (method: HttpMethod, path: string) => {
+ return mockResponses.get(method)?.get(path) ?? Promise.resolve({});
+ };
+
+ httpSetup.get.mockImplementation((path) =>
+ mockMethodImplementation('GET', path as unknown as string)
+ );
+ httpSetup.delete.mockImplementation((path) =>
+ mockMethodImplementation('DELETE', path as unknown as string)
+ );
+ httpSetup.post.mockImplementation((path) =>
+ mockMethodImplementation('POST', path as unknown as string)
+ );
+ httpSetup.put.mockImplementation((path) =>
+ mockMethodImplementation('PUT', path as unknown as string)
+ );
+
+ const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => {
+ const defuse = (promise: Promise) => {
+ promise.catch(() => {});
+ return promise;
+ };
+
+ return mockResponses
+ .get(method)!
+ .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response));
+ };
+
+ const setLoadTemplatesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/index_templates`, response, error);
+
+ const setLoadIndicesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/indices`, response, error);
+
+ const setReloadIndicesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/indices/reload`, response, error);
+
+ const setLoadDataStreamsResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/data_streams`, response, error);
+
+ const setLoadDataStreamResponse = (
+ dataStreamId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) =>
+ mockResponse(
+ 'GET',
+ `${API_BASE_PATH}/data_streams/${encodeURIComponent(dataStreamId)}`,
+ response,
+ error
+ );
+
+ const setDeleteDataStreamResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/delete_data_streams`, response, error);
+
+ const setDeleteTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/delete_index_templates`, response, error);
+
+ const setLoadTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('GET', `${API_BASE_PATH}/index_templates/${templateId}`, response, error);
+
+ const setCreateTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/index_templates`, response, error);
+
+ const setUpdateTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('PUT', `${API_BASE_PATH}/index_templates/${templateId}`, response, error);
+
+ const setUpdateIndexSettingsResponse = (
+ indexName: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('PUT', `${API_BASE_PATH}/settings/${indexName}`, response, error);
+
+ const setSimulateTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/index_templates/simulate`, response, error);
+
+ const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error);
+
+ const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/nodes/plugins`, response, error);
+
+ const setLoadTelemetryResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', '/api/ui_counters/_report', response, error);
return {
setLoadTemplatesResponse,
@@ -166,22 +133,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
setSimulateTemplateResponse,
setLoadComponentTemplatesResponse,
setLoadNodesPluginsResponse,
+ setLoadTelemetryResponse,
};
};
export const init = () => {
- const server = sinon.fakeServer.create();
- server.respondImmediately = true;
-
- // Define default response for unhandled requests.
- // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
- // and we can mock them all with a 200 instead of mocking each one individually.
- server.respondWith([200, {}, 'DefaultSinonMockServerResponse']);
-
- const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
+ const httpSetup = httpServiceMock.createSetupContract();
+ const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup);
return {
- server,
+ httpSetup,
httpRequestsMockHelpers,
};
};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx
index 1682431900a84..c5b077ef00333 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx
@@ -6,11 +6,10 @@
*/
import React from 'react';
-import axios from 'axios';
-import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { merge } from 'lodash';
import SemVer from 'semver/classes/semver';
+import { HttpSetup } from 'src/core/public';
import {
notificationServiceMock,
docLinksServiceMock,
@@ -36,7 +35,6 @@ import {
import { componentTemplatesMockDependencies } from '../../../public/application/components/component_templates/__jest__';
import { init as initHttpRequests } from './http_requests';
-const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const { GlobalFlyoutProvider } = GlobalFlyout;
export const services = {
@@ -64,30 +62,24 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
});
export const setupEnvironment = () => {
- // Mock initialization of services
- // @ts-ignore
- httpService.setup(mockHttpClient);
breadcrumbService.setup(() => undefined);
documentationService.setup(docLinksServiceMock.createStartContract());
notificationService.setup(notificationServiceMock.createSetupContract());
- const { server, httpRequestsMockHelpers } = initHttpRequests();
-
- return {
- server,
- httpRequestsMockHelpers,
- };
+ return initHttpRequests();
};
export const WithAppDependencies =
- (Comp: any, overridingDependencies: any = {}) =>
+ (Comp: any, httpSetup: HttpSetup, overridingDependencies: any = {}) =>
(props: any) => {
+ httpService.setup(httpSetup);
const mergedDependencies = merge({}, appDependencies, overridingDependencies);
+
return (
-
+
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts
index e3295a8f4fb18..9eeab1d3ca78b 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts
@@ -15,6 +15,7 @@ import {
AsyncTestBedConfig,
findTestSubject,
} from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { DataStream } from '../../../common';
import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store';
@@ -46,7 +47,10 @@ export interface DataStreamsTabTestBed extends TestBed {
findDetailPanelIndexTemplateLink: () => ReactWrapper;
}
-export const setup = async (overridingDependencies: any = {}): Promise => {
+export const setup = async (
+ httpSetup: HttpSetup,
+ overridingDependencies: any = {}
+): Promise => {
const testBedConfig: AsyncTestBedConfig = {
store: () => indexManagementStore(services as any),
memoryRouter: {
@@ -57,7 +61,7 @@ export const setup = async (overridingDependencies: any = {}): Promise {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: DataStreamsTabTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('when there are no data streams', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([]);
@@ -53,7 +49,7 @@ describe('Data Streams tab', () => {
});
test('displays an empty prompt', async () => {
- testBed = await setup({
+ testBed = await setup(httpSetup, {
url: urlServiceMock,
});
@@ -69,7 +65,7 @@ describe('Data Streams tab', () => {
});
test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => {
- testBed = await setup({
+ testBed = await setup(httpSetup, {
plugins: {},
url: urlServiceMock,
});
@@ -89,7 +85,7 @@ describe('Data Streams tab', () => {
});
test('when Fleet is enabled, links to Fleet', async () => {
- testBed = await setup({
+ testBed = await setup(httpSetup, {
plugins: { isFleetEnabled: true },
url: urlServiceMock,
});
@@ -112,7 +108,7 @@ describe('Data Streams tab', () => {
});
httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
plugins: {},
url: urlServiceMock,
});
@@ -156,13 +152,13 @@ describe('Data Streams tab', () => {
}),
]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
const indexTemplate = fixtures.getTemplate({ name: 'indexTemplate' });
setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] });
- setLoadTemplateResponse(indexTemplate);
+ setLoadTemplateResponse(indexTemplate.name, indexTemplate);
- testBed = await setup({ history: createMemoryHistory() });
+ testBed = await setup(httpSetup, { history: createMemoryHistory() });
await act(async () => {
testBed.actions.goToDataStreamsList();
});
@@ -181,7 +177,6 @@ describe('Data Streams tab', () => {
test('has a button to reload the data streams', async () => {
const { exists, actions } = testBed;
- const totalRequests = server.requests.length;
expect(exists('reloadButton')).toBe(true);
@@ -189,13 +184,14 @@ describe('Data Streams tab', () => {
actions.clickReloadButton();
});
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/data_streams`,
+ expect.anything()
+ );
});
test('has a switch that will reload the data streams with additional stats when clicked', async () => {
const { exists, actions, table, component } = testBed;
- const totalRequests = server.requests.length;
expect(exists('includeStatsSwitch')).toBe(true);
@@ -205,9 +201,10 @@ describe('Data Streams tab', () => {
});
component.update();
- // A request is sent, but sinon isn't capturing the query parameters for some reason.
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/data_streams`,
+ expect.anything()
+ );
// The table renders with the stats columns though.
const { tableCellsValues } = table.getMetaData('dataStreamTable');
@@ -279,19 +276,17 @@ describe('Data Streams tab', () => {
await clickConfirmDelete();
- const { method, url, requestBody } = server.requests[server.requests.length - 1];
-
- expect(method).toBe('POST');
- expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`);
- expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({
- dataStreams: ['dataStream1'],
- });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_data_streams`,
+ expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) })
+ );
});
});
describe('detail panel', () => {
test('opens when the data stream name in the table is clicked', async () => {
const { actions, findDetailPanel, findDetailPanelTitle } = testBed;
+ httpRequestsMockHelpers.setLoadDataStreamResponse('dataStream1');
await actions.clickNameAt(0);
expect(findDetailPanel().length).toBe(1);
expect(findDetailPanelTitle()).toBe('dataStream1');
@@ -315,13 +310,10 @@ describe('Data Streams tab', () => {
await clickConfirmDelete();
- const { method, url, requestBody } = server.requests[server.requests.length - 1];
-
- expect(method).toBe('POST');
- expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`);
- expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({
- dataStreams: ['dataStream1'],
- });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_data_streams`,
+ expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) })
+ );
});
test('clicking index template name navigates to the index template details', async () => {
@@ -358,9 +350,9 @@ describe('Data Streams tab', () => {
const dataStreamPercentSign = createDataStreamPayload({ name: '%dataStream' });
setLoadDataStreamsResponse([dataStreamPercentSign]);
- setLoadDataStreamResponse(dataStreamPercentSign);
+ setLoadDataStreamResponse(dataStreamPercentSign.name, dataStreamPercentSign);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -396,10 +388,11 @@ describe('Data Streams tab', () => {
name: 'dataStream1',
ilmPolicyName: 'my_ilm_policy',
});
+
setLoadDataStreamsResponse([dataStreamForDetailPanel]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -417,10 +410,11 @@ describe('Data Streams tab', () => {
const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers;
const dataStreamForDetailPanel = createDataStreamPayload({ name: 'dataStream1' });
+
setLoadDataStreamsResponse([dataStreamForDetailPanel]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -442,10 +436,11 @@ describe('Data Streams tab', () => {
name: 'dataStream1',
ilmPolicyName: 'my_ilm_policy',
});
+
setLoadDataStreamsResponse([dataStreamForDetailPanel]);
- setLoadDataStreamResponse(dataStreamForDetailPanel);
+ setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: {
locators: {
@@ -476,9 +471,10 @@ describe('Data Streams tab', () => {
},
});
const nonManagedDataStream = createDataStreamPayload({ name: 'non-managed-data-stream' });
+
httpRequestsMockHelpers.setLoadDataStreamsResponse([managedDataStream, nonManagedDataStream]);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -520,9 +516,10 @@ describe('Data Streams tab', () => {
name: 'hidden-data-stream',
hidden: true,
});
+
httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
url: urlServiceMock,
});
@@ -561,7 +558,7 @@ describe('Data Streams tab', () => {
beforeEach(async () => {
setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]);
- testBed = await setup({ history: createMemoryHistory(), url: urlServiceMock });
+ testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock });
await act(async () => {
testBed.actions.goToDataStreamsList();
});
@@ -599,7 +596,7 @@ describe('Data Streams tab', () => {
actions: { clickNameAt },
find,
} = testBed;
- setLoadDataStreamResponse(dataStreamWithDelete);
+ setLoadDataStreamResponse(dataStreamWithDelete.name, dataStreamWithDelete);
await clickNameAt(1);
expect(find('deleteDataStreamButton').exists()).toBeTruthy();
@@ -610,7 +607,7 @@ describe('Data Streams tab', () => {
actions: { clickNameAt },
find,
} = testBed;
- setLoadDataStreamResponse(dataStreamNoDelete);
+ setLoadDataStreamResponse(dataStreamNoDelete.name, dataStreamNoDelete);
await clickNameAt(0);
expect(find('deleteDataStreamButton').exists()).toBeFalsy();
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts
index 46287fcdcf074..b73985dc8372b 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store';
import { WithAppDependencies, services, TestSubjects } from '../helpers';
@@ -19,8 +20,6 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig);
-
export interface HomeTestBed extends TestBed {
actions: {
selectHomeTab: (tab: 'indicesTab' | 'templatesTab') => void;
@@ -28,7 +27,11 @@ export interface HomeTestBed extends TestBed {
};
}
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(IndexManagementHome, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
const { find } = testBed;
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts
index 60d4b7d3f2317..c3f8a5b17068d 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts
@@ -20,18 +20,14 @@ import { stubWebWorker } from '@kbn/test-jest-helpers';
stubWebWorker();
describe('', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: HomeTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('on component mount', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([]);
- testBed = await setup();
+ testBed = await setup(httpSetup);
await act(async () => {
const { component } = testBed;
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
index 69dcabc287d6b..a16ba0768e675 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts
@@ -13,6 +13,7 @@ import {
AsyncTestBedConfig,
findTestSubject,
} from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateList } from '../../../public/application/sections/home/template_list';
import { TemplateDeserialized } from '../../../common';
import { WithAppDependencies, TestSubjects } from '../helpers';
@@ -25,8 +26,6 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(TemplateList), testBedConfig);
-
const createActions = (testBed: TestBed) => {
/**
* Additional helpers
@@ -132,7 +131,11 @@ const createActions = (testBed: TestBed) => {
};
};
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(TemplateList, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
index bf1a78e3cfe90..3d1360d620ff5 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts
@@ -24,19 +24,15 @@ const removeWhiteSpaceOnArrayValues = (array: any[]) =>
});
describe('Index Templates tab', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: IndexTemplatesTabTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('when there are no index templates of either kind', () => {
test('should display an empty prompt', async () => {
httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] });
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
const { exists, component } = testBed;
component.update();
@@ -54,7 +50,7 @@ describe('Index Templates tab', () => {
});
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
const { exists, component } = testBed;
component.update();
@@ -68,7 +64,8 @@ describe('Index Templates tab', () => {
describe('when there are index templates', () => {
// Add a default loadIndexTemplate response
- httpRequestsMockHelpers.setLoadTemplateResponse(fixtures.getTemplate());
+ const templateMock = fixtures.getTemplate();
+ httpRequestsMockHelpers.setLoadTemplateResponse(templateMock.name, templateMock);
const template1 = fixtures.getTemplate({
name: `a${getRandomString()}`,
@@ -132,7 +129,7 @@ describe('Index Templates tab', () => {
httpRequestsMockHelpers.setLoadTemplatesResponse({ templates, legacyTemplates });
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -194,7 +191,6 @@ describe('Index Templates tab', () => {
test('should have a button to reload the index templates', async () => {
const { exists, actions } = testBed;
- const totalRequests = server.requests.length;
expect(exists('reloadButton')).toBe(true);
@@ -202,9 +198,9 @@ describe('Index Templates tab', () => {
actions.clickReloadButton();
});
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(
- `${API_BASE_PATH}/index_templates`
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates`,
+ expect.anything()
);
});
@@ -235,6 +231,7 @@ describe('Index Templates tab', () => {
const { find, exists, actions, component } = testBed;
// Composable templates
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]);
await actions.clickTemplateAt(0);
expect(exists('templateList')).toBe(true);
expect(exists('templateDetails')).toBe(true);
@@ -246,6 +243,7 @@ describe('Index Templates tab', () => {
});
component.update();
+ httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplates[0].name, legacyTemplates[0]);
await actions.clickTemplateAt(0, true);
expect(exists('templateList')).toBe(true);
@@ -380,13 +378,14 @@ describe('Index Templates tab', () => {
confirmButton!.click();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- expect(latestRequest.method).toBe('POST');
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`);
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({
- templates: [{ name: templates[0].name, isLegacy }],
- });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ templates: [{ name: templates[0].name, isLegacy }],
+ }),
+ })
+ );
});
});
@@ -442,16 +441,14 @@ describe('Index Templates tab', () => {
confirmButton!.click();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- expect(latestRequest.method).toBe('POST');
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`);
-
- // Commenting as I don't find a way to make it work.
- // It keeps on returning the composable template instead of the legacy one
- // expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({
- // templates: [{ name: templateName, isLegacy }],
- // });
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/delete_index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ templates: [{ name: templates[0].name, isLegacy: false }],
+ }),
+ })
+ );
});
});
@@ -463,7 +460,7 @@ describe('Index Templates tab', () => {
isLegacy: true,
});
- httpRequestsMockHelpers.setLoadTemplateResponse(template);
+ httpRequestsMockHelpers.setLoadTemplateResponse(template.name, template);
});
test('should show details when clicking on a template', async () => {
@@ -471,6 +468,7 @@ describe('Index Templates tab', () => {
expect(exists('templateDetails')).toBe(false);
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]);
await actions.clickTemplateAt(0);
expect(exists('templateDetails')).toBe(true);
@@ -480,6 +478,7 @@ describe('Index Templates tab', () => {
beforeEach(async () => {
const { actions } = testBed;
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]);
await actions.clickTemplateAt(0);
});
@@ -544,7 +543,7 @@ describe('Index Templates tab', () => {
const { find, actions, exists } = testBed;
- httpRequestsMockHelpers.setLoadTemplateResponse(template);
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, template);
httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' });
await actions.clickTemplateAt(0);
@@ -598,8 +597,10 @@ describe('Index Templates tab', () => {
const { actions, find, exists } = testBed;
- httpRequestsMockHelpers.setLoadTemplateResponse(templateWithNoOptionalFields);
-
+ httpRequestsMockHelpers.setLoadTemplateResponse(
+ templates[0].name,
+ templateWithNoOptionalFields
+ );
await actions.clickTemplateAt(0);
expect(find('templateDetails.tab').length).toBe(5);
@@ -621,13 +622,12 @@ describe('Index Templates tab', () => {
it('should render an error message if error fetching template details', async () => {
const { actions, exists } = testBed;
const error = {
- status: 404,
+ statusCode: 404,
error: 'Not found',
message: 'Template not found',
};
- httpRequestsMockHelpers.setLoadTemplateResponse(undefined, { body: error });
-
+ httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, undefined, error);
await actions.clickTemplateAt(0);
expect(exists('sectionError')).toBe(true);
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts
index 7daa3cc9e2221..5feb7840f259c 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts
@@ -14,6 +14,7 @@ import {
AsyncTestBedConfig,
findTestSubject,
} from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store';
import { WithAppDependencies, services, TestSubjects } from '../helpers';
@@ -42,9 +43,12 @@ export interface IndicesTestBed extends TestBed {
findDataStreamDetailPanelTitle: () => string;
}
-export const setup = async (overridingDependencies: any = {}): Promise => {
+export const setup = async (
+ httpSetup: HttpSetup,
+ overridingDependencies: any = {}
+): Promise => {
const initTestBed = registerTestBed(
- WithAppDependencies(IndexManagementHome, overridingDependencies),
+ WithAppDependencies(IndexManagementHome, httpSetup, overridingDependencies),
testBedConfig
);
const testBed = await initTestBed();
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts
index 8193d48629f6f..541f2b587b69f 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts
@@ -49,22 +49,20 @@ stubWebWorker();
describe('', () => {
let testBed: IndicesTestBed;
- let server: ReturnType['server'];
+ let httpSetup: ReturnType['httpSetup'];
let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers'];
beforeEach(() => {
- ({ server, httpRequestsMockHelpers } = setupEnvironment());
- });
-
- afterAll(() => {
- server.restore();
+ const mockEnvironment = setupEnvironment();
+ httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers;
+ httpSetup = mockEnvironment.httpSetup;
});
describe('on component mount', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([]);
- testBed = await setup();
+ testBed = await setup(httpSetup);
await act(async () => {
const { component } = testBed;
@@ -118,10 +116,11 @@ describe('', () => {
httpRequestsMockHelpers.setLoadDataStreamsResponse([]);
httpRequestsMockHelpers.setLoadDataStreamResponse(
+ 'dataStream1',
createDataStreamPayload({ name: 'dataStream1' })
);
- testBed = await setup({
+ testBed = await setup(httpSetup, {
history: createMemoryHistory(),
});
@@ -162,7 +161,7 @@ describe('', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]);
- testBed = await setup();
+ testBed = await setup(httpSetup);
const { component, find } = testBed;
component.update();
@@ -174,32 +173,36 @@ describe('', () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('settings');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`
+ );
});
test('should encode indexName when loading mappings in detail panel', async () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('mappings');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`
+ );
});
test('should encode indexName when loading stats in detail panel', async () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('stats');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}`
+ );
});
test('should encode indexName when editing settings in detail panel', async () => {
const { actions } = testBed;
await actions.selectIndexDetailsTab('edit_settings');
- const latestRequest = server.requests[server.requests.length - 1];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`);
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`
+ );
});
});
@@ -222,7 +225,7 @@ describe('', () => {
]);
httpRequestsMockHelpers.setReloadIndicesResponse({ indexNames: [indexNameA, indexNameB] });
- testBed = await setup();
+ testBed = await setup(httpSetup);
const { component, find } = testBed;
component.update();
@@ -236,8 +239,14 @@ describe('', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('refreshIndexMenuButton');
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/refresh`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/refresh`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to close an open index', async () => {
@@ -246,13 +255,20 @@ describe('', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('closeIndexMenuButton');
- // A refresh call was added after closing an index so we need to check the second to last request.
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/close`);
+ // After the index is closed, we imediately do a reload. So we need to expect to see
+ // a reload server call also.
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/close`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to open a closed index', async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
const { component, find, actions } = testBed;
component.update();
@@ -262,9 +278,16 @@ describe('', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('openIndexMenuButton');
- // A refresh call was added after closing an index so we need to check the second to last request.
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/open`);
+ // After the index is opened, we imediately do a reload. So we need to expect to see
+ // a reload server call also.
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/open`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to flush index', async () => {
@@ -273,11 +296,16 @@ describe('', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('flushIndexMenuButton');
- const requestsCount = server.requests.length;
- expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/flush`);
- // After the indices are flushed, we imediately reload them. So we need to expect to see
+ // After the index is flushed, we imediately do a reload. So we need to expect to see
// a reload server call also.
- expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/flush`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test("should be able to clear an index's cache", async () => {
@@ -287,8 +315,16 @@ describe('', () => {
await actions.clickManageContextMenuButton();
await actions.clickContextMenuOption('clearCacheIndexMenuButton');
- const latestRequest = server.requests[server.requests.length - 2];
- expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/clear_cache`);
+ // After the index cache is cleared, we imediately do a reload. So we need to expect to see
+ // a reload server call also.
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/clear_cache`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
test('should be able to unfreeze a frozen index', async () => {
@@ -302,11 +338,17 @@ describe('', () => {
expect(exists('unfreezeIndexMenuButton')).toBe(true);
await actions.clickContextMenuOption('unfreezeIndexMenuButton');
- const requestsCount = server.requests.length;
- expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/unfreeze`);
// After the index is unfrozen, we imediately do a reload. So we need to expect to see
// a reload server call also.
- expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/unfreeze`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
+
// Open context menu once again, since clicking an action will close it.
await actions.clickManageContextMenuButton();
// The unfreeze action should not be present anymore
@@ -326,15 +368,33 @@ describe('', () => {
await actions.clickModalConfirm();
- const requestsCount = server.requests.length;
- expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/forcemerge`);
- // After the index is force merged, we immediately do a reload. So we need to expect to see
+ // After the index force merged, we imediately do a reload. So we need to expect to see
// a reload server call also.
- expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`);
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/forcemerge`,
+ expect.anything()
+ );
+ expect(httpSetup.post).toHaveBeenCalledWith(
+ `${API_BASE_PATH}/indices/reload`,
+ expect.anything()
+ );
});
});
describe('Edit index settings', () => {
+ const indexName = 'test';
+
+ beforeEach(async () => {
+ httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]);
+
+ testBed = await setup(httpSetup);
+ const { component, find } = testBed;
+
+ component.update();
+
+ find('indexTableIndexNameLink').at(0).simulate('click');
+ });
+
test('shows error callout when request fails', async () => {
const { actions, find, component, exists } = testBed;
@@ -347,7 +407,7 @@ describe('', () => {
error: 'Bad Request',
message: 'invalid tier names found in ...',
};
- httpRequestsMockHelpers.setUpdateIndexSettingsResponse(undefined, error);
+ httpRequestsMockHelpers.setUpdateIndexSettingsResponse(indexName, undefined, error);
await actions.selectIndexDetailsTab('edit_settings');
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts
index 9aec6cae7a17e..2ee82c2b4c418 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts
@@ -6,10 +6,11 @@
*/
import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateClone } from '../../../public/application/sections/template_clone';
import { WithAppDependencies } from '../helpers';
-import { formSetup } from './template_form.helpers';
+import { formSetup, TestSubjects } from './template_form.helpers';
import { TEMPLATE_NAME } from './constants';
const testBedConfig: AsyncTestBedConfig = {
@@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(TemplateClone), testBedConfig);
+export const setup = async (httpSetup: HttpSetup) => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(TemplateClone, httpSetup),
+ testBedConfig
+ );
-export const setup: any = formSetup.bind(null, initTestBed);
+ return formSetup(initTestBed);
+};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx
index 31e65625cfdd0..861b1041a4f14 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx
@@ -9,6 +9,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import '../../../test/global_mocks';
+import { API_BASE_PATH } from '../../../common/constants';
import { getComposableTemplate } from '../../../test/fixtures';
import { setupEnvironment } from '../helpers';
@@ -44,23 +45,22 @@ const templateToClone = getComposableTemplate({
describe('', () => {
let testBed: TemplateFormTestBed;
-
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
jest.useFakeTimers();
+ httpRequestsMockHelpers.setLoadTelemetryResponse({});
httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]);
- httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone);
+ httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone.name, templateToClone);
});
afterAll(() => {
- server.restore();
jest.useRealTimers();
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -98,17 +98,19 @@ describe('', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- ...templateToClone,
- name: `${templateToClone.name}-copy`,
- indexPatterns: DEFAULT_INDEX_PATTERNS,
- };
-
- delete expected.template; // As no settings, mappings or aliases have been defined, no "template" param is sent
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ const { priority, version, _kbnMeta } = templateToClone;
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: `${templateToClone.name}-copy`,
+ indexPatterns: DEFAULT_INDEX_PATTERNS,
+ priority,
+ version,
+ _kbnMeta,
+ }),
+ })
+ );
});
});
});
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts
index b039fa83000ed..e57e89a6762c2 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts
@@ -6,12 +6,13 @@
*/
import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateCreate } from '../../../public/application/sections/template_create';
import { WithAppDependencies } from '../helpers';
import { formSetup, TestSubjects } from './template_form.helpers';
-export const setup: any = (isLegacy: boolean = false) => {
+export const setup = async (httpSetup: HttpSetup, isLegacy: boolean = false) => {
const route = isLegacy
? { pathname: '/create_template', search: '?legacy=true' }
: { pathname: '/create_template' };
@@ -25,9 +26,9 @@ export const setup: any = (isLegacy: boolean = false) => {
};
const initTestBed = registerTestBed(
- WithAppDependencies(TemplateCreate),
+ WithAppDependencies(TemplateCreate, httpSetup),
testBedConfig
);
- return formSetup.call(null, initTestBed);
+ return formSetup(initTestBed);
};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx
index 65d3678735689..078a171ac6a75 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx
@@ -9,6 +9,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import '../../../test/global_mocks';
+import { API_BASE_PATH } from '../../../common/constants';
import { setupEnvironment } from '../helpers';
import {
@@ -76,7 +77,7 @@ const componentTemplates = [componentTemplate1, componentTemplate2];
describe('', () => {
let testBed: TemplateFormTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
jest.useFakeTimers();
@@ -89,7 +90,6 @@ describe('', () => {
});
afterAll(() => {
- server.restore();
jest.useRealTimers();
(window as any)['__react-beautiful-dnd-disable-dev-warnings'] = false;
});
@@ -97,7 +97,7 @@ describe('', () => {
describe('composable index template', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
});
@@ -130,7 +130,7 @@ describe('', () => {
describe('legacy index template', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup(true);
+ testBed = await setup(httpSetup, true);
});
});
@@ -150,7 +150,7 @@ describe('', () => {
describe('form validation', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -367,7 +367,7 @@ describe('', () => {
httpRequestsMockHelpers.setLoadNodesPluginsResponse(['mapper-size']);
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
await navigateToMappingsStep();
@@ -415,7 +415,7 @@ describe('', () => {
describe('review (step 6)', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -472,7 +472,7 @@ describe('', () => {
it('should render a warning message if a wildcard is used as an index pattern', async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -505,7 +505,7 @@ describe('', () => {
const MAPPING_FIELDS = [BOOLEAN_MAPPING_FIELD, TEXT_MAPPING_FIELD, KEYWORD_MAPPING_FIELD];
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -534,49 +534,50 @@ describe('', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- name: TEMPLATE_NAME,
- indexPatterns: DEFAULT_INDEX_PATTERNS,
- composedOf: ['test_component_template_1'],
- template: {
- settings: SETTINGS,
- mappings: {
- properties: {
- [BOOLEAN_MAPPING_FIELD.name]: {
- type: BOOLEAN_MAPPING_FIELD.type,
- },
- [TEXT_MAPPING_FIELD.name]: {
- type: TEXT_MAPPING_FIELD.type,
- },
- [KEYWORD_MAPPING_FIELD.name]: {
- type: KEYWORD_MAPPING_FIELD.type,
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: TEMPLATE_NAME,
+ indexPatterns: DEFAULT_INDEX_PATTERNS,
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: false,
+ isLegacy: false,
+ },
+ composedOf: ['test_component_template_1'],
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ properties: {
+ [BOOLEAN_MAPPING_FIELD.name]: {
+ type: BOOLEAN_MAPPING_FIELD.type,
+ },
+ [TEXT_MAPPING_FIELD.name]: {
+ type: TEXT_MAPPING_FIELD.type,
+ },
+ [KEYWORD_MAPPING_FIELD.name]: {
+ type: KEYWORD_MAPPING_FIELD.type,
+ },
+ },
},
+ aliases: ALIASES,
},
- },
- aliases: ALIASES,
- },
- _kbnMeta: {
- type: 'default',
- isLegacy: false,
- hasDatastream: false,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ }),
+ })
+ );
});
it('should surface the API errors from the put HTTP request', async () => {
const { component, actions, find, exists } = testBed;
const error = {
- status: 409,
+ statusCode: 409,
error: 'Conflict',
message: `There is already a template with name '${TEMPLATE_NAME}'`,
};
- httpRequestsMockHelpers.setCreateTemplateResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setCreateTemplateResponse(undefined, error);
await act(async () => {
actions.clickNextButton();
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts
index a7f87d828eb23..97166970568d3 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { TemplateEdit } from '../../../public/application/sections/template_edit';
import { WithAppDependencies } from '../helpers';
@@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(TemplateEdit), testBedConfig);
+export const setup = async (httpSetup: HttpSetup) => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(TemplateEdit, httpSetup),
+ testBedConfig
+ );
-export const setup: any = formSetup.bind(null, initTestBed);
+ return formSetup(initTestBed);
+};
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx
index d4680e7663322..4b94cb92c83d0 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx
@@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils';
import '../../../test/global_mocks';
import * as fixtures from '../../../test/fixtures';
+import { API_BASE_PATH } from '../../../common/constants';
import { setupEnvironment, kibanaVersion } from '../helpers';
import { TEMPLATE_NAME, SETTINGS, ALIASES, MAPPINGS as DEFAULT_MAPPING } from './constants';
@@ -48,7 +49,7 @@ jest.mock('@elastic/eui', () => {
describe('', () => {
let testBed: TemplateFormTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
jest.useFakeTimers();
@@ -56,7 +57,6 @@ describe('', () => {
});
afterAll(() => {
- server.restore();
jest.useRealTimers();
});
@@ -71,12 +71,12 @@ describe('', () => {
});
beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit);
+ httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit);
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -117,24 +117,25 @@ describe('', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- name: 'test',
- indexPatterns: ['myPattern*'],
- dataStream: {
- hidden: true,
- anyUnknownKey: 'should_be_kept',
- },
- version: 1,
- _kbnMeta: {
- type: 'default',
- isLegacy: false,
- hasDatastream: true,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates/test`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: 'test',
+ indexPatterns: ['myPattern*'],
+ version: 1,
+ dataStream: {
+ hidden: true,
+ anyUnknownKey: 'should_be_kept',
+ },
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: true,
+ isLegacy: false,
+ },
+ }),
+ })
+ );
});
});
@@ -148,12 +149,12 @@ describe('', () => {
});
beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit);
+ httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit);
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
});
@@ -225,40 +226,40 @@ describe('', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
- const { version } = templateToEdit;
-
- const expected = {
- name: TEMPLATE_NAME,
- version,
- priority: 3,
- indexPatterns: UPDATED_INDEX_PATTERN,
- template: {
- mappings: {
- properties: {
- [UPDATED_MAPPING_TEXT_FIELD_NAME]: {
- type: 'text',
- store: false,
- index: true,
- fielddata: false,
- eager_global_ordinals: false,
- index_phrases: false,
- norms: true,
- index_options: 'positions',
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: TEMPLATE_NAME,
+ indexPatterns: UPDATED_INDEX_PATTERN,
+ priority: 3,
+ version: templateToEdit.version,
+ _kbnMeta: {
+ type: 'default',
+ hasDatastream: false,
+ isLegacy: templateToEdit._kbnMeta.isLegacy,
+ },
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ properties: {
+ [UPDATED_MAPPING_TEXT_FIELD_NAME]: {
+ type: 'text',
+ index: true,
+ eager_global_ordinals: false,
+ index_phrases: false,
+ norms: true,
+ fielddata: false,
+ store: false,
+ index_options: 'positions',
+ },
+ },
},
+ aliases: ALIASES,
},
- },
- settings: SETTINGS,
- aliases: ALIASES,
- },
- _kbnMeta: {
- type: 'default',
- isLegacy: templateToEdit._kbnMeta.isLegacy,
- hasDatastream: false,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ }),
+ })
+ );
});
});
});
@@ -277,12 +278,12 @@ describe('', () => {
});
beforeAll(() => {
- httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplateToEdit);
+ httpRequestsMockHelpers.setLoadTemplateResponse('my_template', legacyTemplateToEdit);
});
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -305,24 +306,25 @@ describe('', () => {
actions.clickNextButton();
});
- const latestRequest = server.requests[server.requests.length - 1];
-
const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit;
- const expected = {
- name,
- indexPatterns,
- version,
- order,
- template: {
- aliases: undefined,
- mappings: template!.mappings,
- settings: undefined,
- },
- _kbnMeta,
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name,
+ indexPatterns,
+ version,
+ order,
+ template: {
+ aliases: undefined,
+ mappings: template!.mappings,
+ settings: undefined,
+ },
+ _kbnMeta,
+ }),
+ })
+ );
});
});
}
diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts
index 57d0b282d351d..9a68fe41fce27 100644
--- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts
+++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts
@@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils';
import { TestBed, SetupFunc } from '@kbn/test-jest-helpers';
import { TemplateDeserialized } from '../../../common';
-interface MappingField {
+export interface MappingField {
name: string;
type: string;
}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
index f3957e0cc15c9..81f43a1b46073 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
@@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils';
import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock';
import '../../../../../../test/global_mocks';
import { setupEnvironment } from './helpers';
+import { API_BASE_PATH } from './helpers/constants';
import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers';
jest.mock('@elastic/eui', () => {
@@ -34,16 +35,12 @@ jest.mock('@elastic/eui', () => {
describe('', () => {
let testBed: ComponentTemplateCreateTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
-
- afterAll(() => {
- server.restore();
- });
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
describe('On component mount', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -108,7 +105,7 @@ describe('', () => {
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
const { actions, component } = testBed;
@@ -164,37 +161,38 @@ describe('', () => {
component.update();
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- name: COMPONENT_TEMPLATE_NAME,
- template: {
- settings: SETTINGS,
- mappings: {
- properties: {
- [BOOLEAN_MAPPING_FIELD.name]: {
- type: BOOLEAN_MAPPING_FIELD.type,
+ expect(httpSetup.post).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ name: COMPONENT_TEMPLATE_NAME,
+ template: {
+ settings: SETTINGS,
+ mappings: {
+ properties: {
+ [BOOLEAN_MAPPING_FIELD.name]: {
+ type: BOOLEAN_MAPPING_FIELD.type,
+ },
+ },
},
+ aliases: ALIASES,
},
- },
- aliases: ALIASES,
- },
- _kbnMeta: { usedBy: [], isManaged: false },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ _kbnMeta: { usedBy: [], isManaged: false },
+ }),
+ })
+ );
});
test('should surface API errors if the request is unsuccessful', async () => {
const { component, actions, find, exists } = testBed;
const error = {
- status: 409,
+ statusCode: 409,
error: 'Conflict',
message: `There is already a template with name '${COMPONENT_TEMPLATE_NAME}'`,
};
- httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, error);
await act(async () => {
actions.clickNextButton();
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
index 36ea2c27ec4fe..95495af1272c3 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
@@ -32,19 +32,18 @@ const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = {
};
describe('', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: ComponentTemplateDetailsTestBed;
- afterAll(() => {
- server.restore();
- });
-
describe('With component template details', () => {
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE);
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE.name,
+ COMPONENT_TEMPLATE
+ );
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
});
@@ -104,11 +103,12 @@ describe('', () => {
describe('With only required component template fields', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name,
COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS
);
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name,
onClose: () => {},
});
@@ -156,10 +156,13 @@ describe('', () => {
describe('With actions', () => {
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE);
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE.name,
+ COMPONENT_TEMPLATE
+ );
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
actions: [
@@ -197,16 +200,20 @@ describe('', () => {
describe('Error handling', () => {
const error = {
- status: 500,
+ statusCode: 500,
error: 'Internal server error',
message: 'Internal server error',
};
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE.name,
+ undefined,
+ error
+ );
await act(async () => {
- testBed = setup({
+ testBed = setup(httpSetup, {
componentTemplateName: COMPONENT_TEMPLATE.name,
onClose: () => {},
});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
index 1f4abac806276..f3b5b52fe2c41 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
@@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils';
import '../../../../../../test/global_mocks';
import { setupEnvironment } from './helpers';
+import { API_BASE_PATH } from './helpers/constants';
import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers';
jest.mock('@elastic/eui', () => {
@@ -33,11 +34,7 @@ jest.mock('@elastic/eui', () => {
describe('', () => {
let testBed: ComponentTemplateEditTestBed;
- const { server, httpRequestsMockHelpers } = setupEnvironment();
-
- afterAll(() => {
- server.restore();
- });
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
const COMPONENT_TEMPLATE_NAME = 'comp-1';
const COMPONENT_TEMPLATE_TO_EDIT = {
@@ -49,10 +46,13 @@ describe('', () => {
};
beforeEach(async () => {
- httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE_TO_EDIT);
+ httpRequestsMockHelpers.setLoadComponentTemplateResponse(
+ COMPONENT_TEMPLATE_TO_EDIT.name,
+ COMPONENT_TEMPLATE_TO_EDIT
+ );
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -98,17 +98,18 @@ describe('', () => {
component.update();
- const latestRequest = server.requests[server.requests.length - 1];
-
- const expected = {
- version: 1,
- ...COMPONENT_TEMPLATE_TO_EDIT,
- template: {
- ...COMPONENT_TEMPLATE_TO_EDIT.template,
- },
- };
-
- expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
+ expect(httpSetup.put).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates/${COMPONENT_TEMPLATE_TO_EDIT.name}`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ ...COMPONENT_TEMPLATE_TO_EDIT,
+ template: {
+ ...COMPONENT_TEMPLATE_TO_EDIT.template,
+ },
+ version: 1,
+ }),
+ })
+ );
});
});
});
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
index dee15f2ae3a45..a3e9524dcd3ca 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
@@ -16,16 +16,12 @@ import { API_BASE_PATH } from './helpers/constants';
const { setup } = pageHelpers.componentTemplateList;
describe('', () => {
- const { server, httpRequestsMockHelpers } = setupEnvironment();
+ const { httpSetup, httpRequestsMockHelpers } = setupEnvironment();
let testBed: ComponentTemplateListTestBed;
- afterAll(() => {
- server.restore();
- });
-
beforeEach(async () => {
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -69,7 +65,6 @@ describe('', () => {
test('should reload the component templates data', async () => {
const { component, actions } = testBed;
- const totalRequests = server.requests.length;
await act(async () => {
actions.clickReloadButton();
@@ -77,9 +72,9 @@ describe('', () => {
component.update();
- expect(server.requests.length).toBe(totalRequests + 1);
- expect(server.requests[server.requests.length - 1].url).toBe(
- `${API_BASE_PATH}/component_templates`
+ expect(httpSetup.get).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates`,
+ expect.anything()
);
});
@@ -103,7 +98,7 @@ describe('', () => {
expect(modal).not.toBe(null);
expect(modal!.textContent).toContain('Delete component template');
- httpRequestsMockHelpers.setDeleteComponentTemplateResponse({
+ httpRequestsMockHelpers.setDeleteComponentTemplateResponse(componentTemplateName, {
itemsDeleted: [componentTemplateName],
errors: [],
});
@@ -114,13 +109,10 @@ describe('', () => {
component.update();
- const deleteRequest = server.requests[server.requests.length - 2];
-
- expect(deleteRequest.method).toBe('DELETE');
- expect(deleteRequest.url).toBe(
- `${API_BASE_PATH}/component_templates/${componentTemplateName}`
+ expect(httpSetup.delete).toHaveBeenLastCalledWith(
+ `${API_BASE_PATH}/component_templates/${componentTemplateName}`,
+ expect.anything()
);
- expect(deleteRequest.status).toEqual(200);
});
});
@@ -129,7 +121,7 @@ describe('', () => {
httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]);
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
@@ -147,15 +139,15 @@ describe('', () => {
describe('Error handling', () => {
beforeEach(async () => {
const error = {
- status: 500,
+ statusCode: 500,
error: 'Internal server error',
message: 'Internal server error',
};
- httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, { body: error });
+ httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, error);
await act(async () => {
- testBed = await setup();
+ testBed = await setup(httpSetup);
});
testBed.component.update();
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
index 18b5bbfd775bb..846c921e776c3 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { BASE_PATH } from '../../../../../../../common';
import { ComponentTemplateCreate } from '../../../component_template_wizard';
@@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateCreate), testBedConfig);
-
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(ComponentTemplateCreate, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts
index cdf376028ff1d..18fe2b59f21c6 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { WithAppDependencies } from './setup_environment';
import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details';
@@ -43,9 +44,9 @@ const createActions = (testBed: TestBed) =
};
};
-export const setup = (props: any): ComponentTemplateDetailsTestBed => {
+export const setup = (httpSetup: HttpSetup, props: any): ComponentTemplateDetailsTestBed => {
const setupTestBed = registerTestBed(
- WithAppDependencies(ComponentTemplateDetailsFlyoutContent),
+ WithAppDependencies(ComponentTemplateDetailsFlyoutContent, httpSetup),
{
memoryRouter: {
wrapComponent: false,
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
index 6e0f9d55ef7f0..dfc73e0ccafb0 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts
@@ -6,6 +6,7 @@
*/
import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers';
+import { HttpSetup } from 'src/core/public';
import { BASE_PATH } from '../../../../../../../common';
import { ComponentTemplateEdit } from '../../../component_template_wizard';
@@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateEdit), testBedConfig);
-
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(ComponentTemplateEdit, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts
index 2a01518e25466..3005eae0d6bf1 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts
@@ -6,6 +6,7 @@
*/
import { act } from 'react-dom/test-utils';
+import { HttpSetup } from 'src/core/public';
import {
registerTestBed,
@@ -26,8 +27,6 @@ const testBedConfig: AsyncTestBedConfig = {
doMountAsync: true,
};
-const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateList), testBedConfig);
-
export type ComponentTemplateListTestBed = TestBed & {
actions: ReturnType;
};
@@ -74,7 +73,11 @@ const createActions = (testBed: TestBed) => {
};
};
-export const setup = async (): Promise => {
+export const setup = async (httpSetup: HttpSetup): Promise => {
+ const initTestBed = registerTestBed(
+ WithAppDependencies(ComponentTemplateList, httpSetup),
+ testBedConfig
+ );
const testBed = await initTestBed();
return {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
index 520da90c58862..025f34066908c 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts
@@ -5,65 +5,74 @@
* 2.0.
*/
-import sinon, { SinonFakeServer } from 'sinon';
-import {
- ComponentTemplateListItem,
- ComponentTemplateDeserialized,
- ComponentTemplateSerialized,
-} from '../../../shared_imports';
+import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks';
import { API_BASE_PATH } from './constants';
+type HttpResponse = Record | any[];
+type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST';
+
+export interface ResponseError {
+ statusCode: number;
+ message: string | Error;
+ attributes?: Record;
+}
+
// Register helpers to mock HTTP Requests
-const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
- const setLoadComponentTemplatesResponse = (
- response?: ComponentTemplateListItem[],
- error?: any
- ) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
+const registerHttpRequestMockHelpers = (
+ httpSetup: ReturnType
+) => {
+ const mockResponses = new Map>>(
+ ['GET', 'PUT', 'DELETE', 'POST'].map(
+ (method) => [method, new Map()] as [HttpMethod, Map>]
+ )
+ );
- server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
+ const mockMethodImplementation = (method: HttpMethod, path: string) => {
+ return mockResponses.get(method)?.get(path) ?? Promise.resolve({});
};
- const setLoadComponentTemplateResponse = (
- response?: ComponentTemplateDeserialized,
- error?: any
- ) => {
- const status = error ? error.status || 400 : 200;
- const body = error ? error.body : response;
+ httpSetup.get.mockImplementation((path) =>
+ mockMethodImplementation('GET', path as unknown as string)
+ );
+ httpSetup.delete.mockImplementation((path) =>
+ mockMethodImplementation('DELETE', path as unknown as string)
+ );
+ httpSetup.post.mockImplementation((path) =>
+ mockMethodImplementation('POST', path as unknown as string)
+ );
+ httpSetup.put.mockImplementation((path) =>
+ mockMethodImplementation('PUT', path as unknown as string)
+ );
- server.respondWith('GET', `${API_BASE_PATH}/component_templates/:name`, [
- status,
- { 'Content-Type': 'application/json' },
- JSON.stringify(body),
- ]);
- };
+ const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => {
+ const defuse = (promise: Promise) => {
+ promise.catch(() => {});
+ return promise;
+ };
- const setDeleteComponentTemplateResponse = (response?: object) => {
- server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [
- 200,
- { 'Content-Type': 'application/json' },
- JSON.stringify(response),
- ]);
+ return mockResponses
+ .get(method)!
+ .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response));
};
- const setCreateComponentTemplateResponse = (
- response?: ComponentTemplateSerialized,
- error?: any
- ) => {
- const status = error ? error.body.status || 400 : 200;
- const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
+ const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error);
- server.respondWith('POST', `${API_BASE_PATH}/component_templates`, [
- status,
- { 'Content-Type': 'application/json' },
- body,
- ]);
- };
+ const setLoadComponentTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) => mockResponse('GET', `${API_BASE_PATH}/component_templates/${templateId}`, response, error);
+
+ const setDeleteComponentTemplateResponse = (
+ templateId: string,
+ response?: HttpResponse,
+ error?: ResponseError
+ ) =>
+ mockResponse('DELETE', `${API_BASE_PATH}/component_templates/${templateId}`, response, error);
+
+ const setCreateComponentTemplateResponse = (response?: HttpResponse, error?: ResponseError) =>
+ mockResponse('POST', `${API_BASE_PATH}/component_templates`, response, error);
return {
setLoadComponentTemplatesResponse,
@@ -74,18 +83,11 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
};
export const init = () => {
- const server = sinon.fakeServer.create();
- server.respondImmediately = true;
-
- // Define default response for unhandled requests.
- // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
- // and we can mock them all with a 200 instead of mocking each one individually.
- server.respondWith([200, {}, 'DefaultMockedResponse']);
-
- const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
+ const httpSetup = httpServiceMock.createSetupContract();
+ const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup);
return {
- server,
+ httpSetup,
httpRequestsMockHelpers,
};
};
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
index d532eaaba8923..9c2017ad651f1 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
@@ -6,8 +6,6 @@
*/
import React from 'react';
-import axios from 'axios';
-import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { HttpSetup } from 'kibana/public';
import {
@@ -24,7 +22,6 @@ import { ComponentTemplatesProvider } from '../../../component_templates_context
import { init as initHttpRequests } from './http_requests';
import { API_BASE_PATH } from './constants';
-const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const { GlobalFlyoutProvider } = GlobalFlyout;
// We provide the minimum deps required to make the tests pass
@@ -32,30 +29,23 @@ const appDependencies = {
docLinks: {} as any,
} as any;
-export const componentTemplatesDependencies = {
- httpClient: mockHttpClient as unknown as HttpSetup,
+export const componentTemplatesDependencies = (httpSetup: HttpSetup) => ({
+ httpClient: httpSetup,
apiBasePath: API_BASE_PATH,
trackMetric: () => {},
docLinks: docLinksServiceMock.createStartContract(),
toasts: notificationServiceMock.createSetupContract().toasts,
setBreadcrumbs: () => {},
getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp,
-};
+});
-export const setupEnvironment = () => {
- const { server, httpRequestsMockHelpers } = initHttpRequests();
+export const setupEnvironment = initHttpRequests;
- return {
- server,
- httpRequestsMockHelpers,
- };
-};
-
-export const WithAppDependencies = (Comp: any) => (props: any) =>
+export const WithAppDependencies = (Comp: any, httpSetup: HttpSetup) => (props: any) =>
(
-
+
diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts
index 1504e33ecacab..d0bfecbd386be 100644
--- a/x-pack/plugins/lens/common/constants.ts
+++ b/x-pack/plugins/lens/common/constants.ts
@@ -45,6 +45,7 @@ export const LegendDisplay = {
export const layerTypes = {
DATA: 'data',
REFERENCELINE: 'referenceLine',
+ ANNOTATIONS: 'annotations',
} as const;
// might collide with user-supplied field names, try to make as unique as possible
diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts
new file mode 100644
index 0000000000000..45b4bf31c0cdc
--- /dev/null
+++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts
@@ -0,0 +1,67 @@
+/*
+ * 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 {
+ EventAnnotationConfig,
+ EventAnnotationOutput,
+} from '../../../../../../../src/plugins/event_annotation/common';
+import type { ExpressionFunctionDefinition } from '../../../../../../../src/plugins/expressions/common';
+import { layerTypes } from '../../../constants';
+
+export interface XYAnnotationLayerConfig {
+ layerId: string;
+ layerType: typeof layerTypes.ANNOTATIONS;
+ annotations: EventAnnotationConfig[];
+ hide?: boolean;
+}
+
+export interface AnnotationLayerArgs {
+ annotations: EventAnnotationOutput[];
+ layerId: string;
+ layerType: typeof layerTypes.ANNOTATIONS;
+ hide?: boolean;
+}
+export type XYAnnotationLayerArgsResult = AnnotationLayerArgs & {
+ type: 'lens_xy_annotation_layer';
+};
+export function annotationLayerConfig(): ExpressionFunctionDefinition<
+ 'lens_xy_annotation_layer',
+ null,
+ AnnotationLayerArgs,
+ XYAnnotationLayerArgsResult
+> {
+ return {
+ name: 'lens_xy_annotation_layer',
+ aliases: [],
+ type: 'lens_xy_annotation_layer',
+ inputTypes: ['null'],
+ help: 'Annotation layer in lens',
+ args: {
+ layerId: {
+ types: ['string'],
+ help: '',
+ },
+ layerType: { types: ['string'], options: [layerTypes.ANNOTATIONS], help: '' },
+ hide: {
+ types: ['boolean'],
+ default: false,
+ help: 'Show details',
+ },
+ annotations: {
+ types: ['manual_event_annotation'],
+ help: '',
+ multi: true,
+ },
+ },
+ fn: (input, args) => {
+ return {
+ type: 'lens_xy_annotation_layer',
+ ...args,
+ };
+ },
+ };
+}
diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts
index 0b27ce7d6ed85..df27229bdb81f 100644
--- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts
+++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts
@@ -6,7 +6,12 @@
*/
import { XYDataLayerConfig } from './data_layer_config';
import { XYReferenceLineLayerConfig } from './reference_line_layer_config';
+import { XYAnnotationLayerConfig } from './annotation_layer_config';
export * from './data_layer_config';
export * from './reference_line_layer_config';
+export * from './annotation_layer_config';
-export type XYLayerConfig = XYDataLayerConfig | XYReferenceLineLayerConfig;
+export type XYLayerConfig =
+ | XYDataLayerConfig
+ | XYReferenceLineLayerConfig
+ | XYAnnotationLayerConfig;
diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts
index 940896a2079e6..4520f0c99c3e9 100644
--- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts
+++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts
@@ -9,13 +9,14 @@ import type { AxisExtentConfigResult, AxisTitlesVisibilityConfigResult } from '.
import type { FittingFunction } from './fitting_function';
import type { EndValue } from './end_value';
import type { GridlinesConfigResult } from './grid_lines_config';
-import type { DataLayerArgs } from './layer_config';
+import type { AnnotationLayerArgs, DataLayerArgs } from './layer_config';
import type { LegendConfigResult } from './legend_config';
import type { TickLabelsConfigResult } from './tick_labels_config';
import type { LabelsOrientationConfigResult } from './labels_orientation_config';
import type { ValueLabelConfig } from '../../types';
export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X';
+export type XYLayerArgs = DataLayerArgs | AnnotationLayerArgs;
// Arguments to XY chart expression, with computed properties
export interface XYArgs {
@@ -28,7 +29,7 @@ export interface XYArgs {
yRightExtent: AxisExtentConfigResult;
legend: LegendConfigResult;
valueLabels: ValueLabelConfig;
- layers: DataLayerArgs[];
+ layers: XYLayerArgs[];
fittingFunction?: FittingFunction;
endValue?: EndValue;
emphasizeFitting?: boolean;
diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts
index d0f278d382be9..6d73e8eb9ba5f 100644
--- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts
+++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts
@@ -128,8 +128,12 @@ export const xyChart: ExpressionFunctionDefinition<
}),
},
layers: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- types: ['lens_xy_data_layer', 'lens_xy_referenceLine_layer'] as any,
+ types: [
+ 'lens_xy_data_layer',
+ 'lens_xy_referenceLine_layer',
+ 'lens_xy_annotation_layer',
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ] as any,
help: 'Layers of visual series',
multi: true,
},
diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json
index 17a58a0f96770..18f33adf40840 100644
--- a/x-pack/plugins/lens/kibana.json
+++ b/x-pack/plugins/lens/kibana.json
@@ -21,7 +21,8 @@
"presentationUtil",
"dataViewFieldEditor",
"expressionGauge",
- "expressionHeatmap"
+ "expressionHeatmap",
+ "eventAnnotation"
],
"optionalPlugins": [
"usageCollection",
diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss
index 83b0a39be9229..5e859c1a93818 100644
--- a/x-pack/plugins/lens/public/app_plugin/app.scss
+++ b/x-pack/plugins/lens/public/app_plugin/app.scss
@@ -38,6 +38,13 @@
fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade);
}
}
+.lensAnnotationIconNoFill {
+ fill: none;
+}
+
+.lensAnnotationIconFill {
+ fill: $euiColorGhost;
+}
// Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future.
.lnsNavItem__goBack {
diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx
new file mode 100644
index 0000000000000..fe19dc7e4c8fc
--- /dev/null
+++ b/x-pack/plugins/lens/public/assets/annotation_icons/circle.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 * as React from 'react';
+import { EuiIconProps } from '@elastic/eui';
+import classnames from 'classnames';
+
+export const IconCircle = ({ title, titleId, ...props }: Omit) => (
+
+);
diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx
similarity index 73%
rename from x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx
rename to x-pack/plugins/lens/public/assets/annotation_icons/index.tsx
index 8473d3971c66f..9e641d495582f 100644
--- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx
+++ b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx
@@ -5,6 +5,5 @@
* 2.0.
*/
-export * from './authentications';
-export * from './hosts';
-export * from './unique_ips';
+export { IconCircle } from './circle';
+export { IconTriangle } from './triangle';
diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx
new file mode 100644
index 0000000000000..9924c049004cf
--- /dev/null
+++ b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx
@@ -0,0 +1,30 @@
+/*
+ * 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 * as React from 'react';
+import { EuiIconProps } from '@elastic/eui';
+import classnames from 'classnames';
+
+export const IconTriangle = ({ title, titleId, ...props }: Omit) => (
+
+);
diff --git a/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx
new file mode 100644
index 0000000000000..63fc9023533f6
--- /dev/null
+++ b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 { EuiIconProps } from '@elastic/eui';
+
+export const LensIconChartBarAnnotations = ({
+ title,
+ titleId,
+ ...props
+}: Omit) => (
+
+);
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx
index e88b04588d2e0..f0e0911b708fd 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx
@@ -18,6 +18,7 @@ import {
getCustomDropTarget,
getAdditionalClassesOnDroppable,
getAdditionalClassesOnEnter,
+ getDropProps,
} from './drop_targets_utils';
export function DraggableDimensionButton({
@@ -59,8 +60,8 @@ export function DraggableDimensionButton({
}) {
const { dragging } = useContext(DragContext);
- const dropProps = layerDatasource.getDropProps({
- ...layerDatasourceDropProps,
+ const dropProps = getDropProps(layerDatasource, {
+ ...(layerDatasourceDropProps || {}),
dragging,
columnId,
filterOperations: group.filterOperations,
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx
index 7d92eb9d22cbb..a293af4d11bfe 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx
@@ -9,7 +9,7 @@ import React from 'react';
import classNames from 'classnames';
import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { DropType } from '../../../../types';
+import { Datasource, DropType, GetDropProps } from '../../../../types';
function getPropsForDropType(type: 'swap' | 'duplicate' | 'combine') {
switch (type) {
@@ -129,3 +129,13 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => {
return 'lnsDragDrop-notCompatible';
}
};
+
+export const getDropProps = (
+ layerDatasource: Datasource,
+ layerDatasourceDropProps: GetDropProps
+) => {
+ if (layerDatasource) {
+ return layerDatasource.getDropProps(layerDatasourceDropProps);
+ }
+ return;
+};
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
index 1ba3ff8f6ac34..f2118bda216b8 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx
@@ -14,7 +14,11 @@ import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop
import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../../types';
import { LayerDatasourceDropProps } from '../types';
-import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils';
+import {
+ getCustomDropTarget,
+ getAdditionalClassesOnDroppable,
+ getDropProps,
+} from './drop_targets_utils';
const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', {
defaultMessage: 'Empty dimension',
@@ -24,32 +28,47 @@ interface EmptyButtonProps {
columnId: string;
onClick: (id: string) => void;
group: VisualizationDimensionGroupConfig;
+ labels?: {
+ ariaLabel: (label: string) => string;
+ label: JSX.Element | string;
+ };
}
-const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => (
-
+ i18n.translate('xpack.lens.indexPattern.addColumnAriaLabel', {
defaultMessage: 'Add or drag-and-drop a field to {groupLabel}',
- values: { groupLabel: group.groupLabel },
- })}
- data-test-subj="lns-empty-dimension"
- onClick={() => {
- onClick(columnId);
- }}
- >
+ values: { groupLabel: l },
+ }),
+ label: (
-
-);
+ ),
+};
+
+const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => {
+ const { buttonAriaLabel, buttonLabel } = group.labels || {};
+ return (
+ {
+ onClick(columnId);
+ }}
+ >
+ {buttonLabel || defaultButtonLabels.label}
+
+ );
+};
const SuggestedValueButton = ({ columnId, group, onClick }: EmptyButtonProps) => (
contentProps={{
className: 'lnsLayerPanel__triggerTextContent',
}}
- aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnAriaLabel', {
- defaultMessage: 'Add or drag-and-drop a field to {groupLabel}',
- values: { groupLabel: group.groupLabel },
+ aria-label={i18n.translate('xpack.lens.indexPattern.suggestedValueAriaLabel', {
+ defaultMessage: 'Suggested value: {value} for {groupLabel}',
+ values: { value: group.suggestedValue?.(), groupLabel: group.groupLabel },
})}
data-test-subj="lns-empty-dimension-suggested-value"
onClick={() => {
@@ -112,8 +131,8 @@ export function EmptyDimensionButton({
setNewColumnId(generateId());
}, [itemIndex]);
- const dropProps = layerDatasource.getDropProps({
- ...layerDatasourceDropProps,
+ const dropProps = getDropProps(layerDatasource, {
+ ...(layerDatasourceDropProps || {}),
dragging,
columnId: newColumnId,
filterOperations: group.filterOperations,
@@ -151,6 +170,12 @@ export function EmptyDimensionButton({
[value, onDrop]
);
+ const buttonProps: EmptyButtonProps = {
+ columnId: value.columnId,
+ onClick,
+ group,
+ };
+
return (
{typeof group.suggestedValue?.() === 'number' ? (
-
+
) : (
-
+
)}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx
index cd26cd3197587..b234b18f5262f 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx
@@ -20,7 +20,7 @@ import { LayerPanel } from './layer_panel';
import { coreMock } from 'src/core/public/mocks';
import { generateId } from '../../../id_generator';
import { mountWithProvider } from '../../../mocks';
-import { layerTypes } from '../../../../common';
+import { LayerType, layerTypes } from '../../../../common';
import { ReactWrapper } from 'enzyme';
import { addLayer } from '../../../state_management';
@@ -231,14 +231,17 @@ describe('ConfigPanel', () => {
});
describe('initial default value', () => {
- function clickToAddLayer(instance: ReactWrapper) {
+ function clickToAddLayer(
+ instance: ReactWrapper,
+ layerType: LayerType = layerTypes.REFERENCELINE
+ ) {
act(() => {
instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click');
});
instance.update();
act(() => {
instance
- .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.REFERENCELINE}"]`)
+ .find(`[data-test-subj="lnsLayerAddButton-${layerType}"]`)
.first()
.simulate('click');
});
@@ -288,8 +291,6 @@ describe('ConfigPanel', () => {
{
groupId: 'testGroup',
columnId: 'myColumn',
- dataType: 'number',
- label: 'Initial value',
staticValue: 100,
},
],
@@ -319,8 +320,6 @@ describe('ConfigPanel', () => {
{
groupId: 'testGroup',
columnId: 'myColumn',
- dataType: 'number',
- label: 'Initial value',
staticValue: 100,
},
],
@@ -335,9 +334,7 @@ describe('ConfigPanel', () => {
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(datasourceMap.testDatasource.initializeDimension).toHaveBeenCalledWith({}, 'newId', {
columnId: 'myColumn',
- dataType: 'number',
groupId: 'testGroup',
- label: 'Initial value',
staticValue: 100,
});
});
@@ -354,8 +351,6 @@ describe('ConfigPanel', () => {
{
groupId: 'a',
columnId: 'newId',
- dataType: 'number',
- label: 'Initial value',
staticValue: 100,
},
],
@@ -374,11 +369,65 @@ describe('ConfigPanel', () => {
{
groupId: 'a',
columnId: 'newId',
- dataType: 'number',
- label: 'Initial value',
staticValue: 100,
}
);
});
+
+ it('When visualization is `noDatasource` should not run datasource methods', async () => {
+ const datasourceMap = mockDatasourceMap();
+
+ const visualizationMap = mockVisualizationMap();
+ visualizationMap.testVis.setDimension = jest.fn();
+ visualizationMap.testVis.getSupportedLayers = jest.fn(() => [
+ {
+ type: layerTypes.DATA,
+ label: 'Data Layer',
+ initialDimensions: [
+ {
+ groupId: 'testGroup',
+ columnId: 'myColumn',
+ staticValue: 100,
+ },
+ ],
+ },
+ {
+ type: layerTypes.REFERENCELINE,
+ label: 'Reference layer',
+ },
+ {
+ type: layerTypes.ANNOTATIONS,
+ label: 'Annotations Layer',
+ noDatasource: true,
+ initialDimensions: [
+ {
+ groupId: 'a',
+ columnId: 'newId',
+ staticValue: 100,
+ },
+ ],
+ },
+ ]);
+
+ datasourceMap.testDatasource.initializeDimension = jest.fn();
+ const props = getDefaultProps({ visualizationMap, datasourceMap });
+ const { instance, lensStore } = await prepareAndMountComponent(props);
+ await clickToAddLayer(instance, layerTypes.ANNOTATIONS);
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
+
+ expect(visualizationMap.testVis.setDimension).toHaveBeenCalledWith({
+ columnId: 'newId',
+ frame: {
+ activeData: undefined,
+ datasourceLayers: {
+ a: expect.anything(),
+ },
+ },
+ groupId: 'a',
+ layerId: 'newId',
+ prevState: undefined,
+ });
+ expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled();
+ });
});
});
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
index d3574abe4f57a..163d1b8ce8e61 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
@@ -135,61 +135,57 @@ export function LayerPanels(
[dispatchLens]
);
- const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers;
-
return (
- {layerIds.map((layerId, layerIndex) =>
- datasourcePublicAPIs[layerId] ? (
- {
- // avoid state update if the datasource does not support initializeDimension
- if (
- activeDatasourceId != null &&
- datasourceMap[activeDatasourceId]?.initializeDimension
- ) {
- dispatchLens(
- setLayerDefaultDimension({
- layerId,
- columnId,
- groupId,
- })
- );
- }
- }}
- onRemoveLayer={() => {
+ {layerIds.map((layerId, layerIndex) => (
+ {
+ // avoid state update if the datasource does not support initializeDimension
+ if (
+ activeDatasourceId != null &&
+ datasourceMap[activeDatasourceId]?.initializeDimension
+ ) {
dispatchLens(
- removeOrClearLayer({
- visualizationId: activeVisualization.id,
+ setLayerDefaultDimension({
layerId,
- layerIds,
+ columnId,
+ groupId,
})
);
- removeLayerRef(layerId);
- }}
- toggleFullscreen={toggleFullscreen}
- />
- ) : null
- )}
+ }
+ }}
+ onRemoveLayer={() => {
+ dispatchLens(
+ removeOrClearLayer({
+ visualizationId: activeVisualization.id,
+ layerId,
+ layerIds,
+ })
+ );
+ removeLayerRef(layerId);
+ }}
+ toggleFullscreen={toggleFullscreen}
+ />
+ ))}
Hello!,
+ style: {},
+ },
+};
+
describe('LayerPanel', () => {
let mockVisualization: jest.Mocked;
let mockVisualization2: jest.Mocked;
let mockDatasource: DatasourceMock;
+ mockDatasource = createMockDatasource('testDatasource');
let frame: FramePublicAPI;
function getDefaultProps() {
@@ -611,17 +623,6 @@ describe('LayerPanel', () => {
nextLabel: '',
});
- const draggingField = {
- field: { name: 'dragged' },
- indexPatternId: 'a',
- id: '1',
- humanData: { label: 'Label' },
- ghost: {
- children: ,
- style: {},
- },
- };
-
const { instance } = await mountWithProvider(
@@ -666,17 +667,6 @@ describe('LayerPanel', () => {
columnId !== 'a' ? { dropTypes: ['field_replace'], nextLabel: '' } : undefined
);
- const draggingField = {
- field: { name: 'dragged' },
- indexPatternId: 'a',
- id: '1',
- humanData: { label: 'Label' },
- ghost: {
- children: ,
- style: {},
- },
- };
-
const { instance } = await mountWithProvider(
@@ -985,4 +975,52 @@ describe('LayerPanel', () => {
);
});
});
+ describe('dimension trigger', () => {
+ it('should render datasource dimension trigger if there is layer datasource', async () => {
+ mockVisualization.getConfiguration.mockReturnValue({
+ groups: [
+ {
+ groupLabel: 'A',
+ groupId: 'a',
+ accessors: [{ columnId: 'x' }],
+ filterOperations: () => true,
+ supportsMoreColumns: false,
+ dataTestSubj: 'lnsGroup',
+ },
+ ],
+ });
+ await mountWithProvider();
+ expect(mockDatasource.renderDimensionTrigger).toHaveBeenCalled();
+ });
+
+ it('should render visualization dimension trigger if there is no layer datasource', async () => {
+ mockVisualization.getConfiguration.mockReturnValue({
+ groups: [
+ {
+ groupLabel: 'A',
+ groupId: 'a',
+ accessors: [{ columnId: 'x' }],
+ filterOperations: () => true,
+ supportsMoreColumns: false,
+ dataTestSubj: 'lnsGroup',
+ },
+ ],
+ });
+
+ const props = getDefaultProps();
+ const propsWithVisOnlyLayer = {
+ ...props,
+ framePublicAPI: { ...props.framePublicAPI, datasourceLayers: {} },
+ };
+
+ mockVisualization.renderDimensionTrigger = jest.fn();
+ mockVisualization.getUniqueLabels = jest.fn(() => ({
+ x: 'A',
+ }));
+
+ await mountWithProvider();
+ expect(mockDatasource.renderDimensionTrigger).not.toHaveBeenCalled();
+ expect(mockVisualization.renderDimensionTrigger).toHaveBeenCalled();
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
index 404a40832fc2f..366d3f93bf842 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
@@ -81,10 +81,10 @@ export function LayerPanel(
updateDatasourceAsync,
visualizationState,
} = props;
- const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId];
- const dateRange = useLensSelector(selectResolvedDateRange);
+
const datasourceStates = useLensSelector(selectDatasourceStates);
const isFullscreen = useLensSelector(selectIsFullscreenDatasource);
+ const dateRange = useLensSelector(selectResolvedDateRange);
useEffect(() => {
setActiveDimension(initialActiveDimensionState);
@@ -104,8 +104,10 @@ export function LayerPanel(
activeData: props.framePublicAPI.activeData,
};
- const datasourceId = datasourcePublicAPI.datasourceId;
- const layerDatasourceState = datasourceStates[datasourceId].state;
+ const datasourcePublicAPI = framePublicAPI.datasourceLayers?.[layerId];
+ const datasourceId = datasourcePublicAPI?.datasourceId;
+ const layerDatasourceState = datasourceStates?.[datasourceId]?.state;
+ const layerDatasource = props.datasourceMap[datasourceId];
const layerDatasourceDropProps = useMemo(
() => ({
@@ -118,12 +120,9 @@ export function LayerPanel(
[layerId, layerDatasourceState, datasourceId, updateDatasource]
);
- const layerDatasource = props.datasourceMap[datasourceId];
-
const layerDatasourceConfigProps = {
...layerDatasourceDropProps,
frame: props.framePublicAPI,
- activeData: props.framePublicAPI.activeData,
dateRange,
};
@@ -137,11 +136,15 @@ export function LayerPanel(
activeVisualization,
]
);
+
+ const columnLabelMap =
+ !layerDatasource && activeVisualization.getUniqueLabels
+ ? activeVisualization.getUniqueLabels(props.visualizationState)
+ : layerDatasource?.uniqueLabels?.(layerDatasourceConfigProps?.state);
+
const isEmptyLayer = !groups.some((d) => d.accessors.length > 0);
const { activeId, activeGroup } = activeDimension;
- const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state);
-
const { setDimension, removeDimension } = activeVisualization;
const allAccessors = groups.flatMap((group) =>
@@ -154,7 +157,7 @@ export function LayerPanel(
registerNewRef: registerNewButtonRef,
} = useFocusUpdate(allAccessors);
- const layerDatasourceOnDrop = layerDatasource.onDrop;
+ const layerDatasourceOnDrop = layerDatasource?.onDrop;
const onDrop = useMemo(() => {
return (
@@ -180,16 +183,18 @@ export function LayerPanel(
const filterOperations = group?.filterOperations || (() => false);
- const dropResult = layerDatasourceOnDrop({
- ...layerDatasourceDropProps,
- droppedItem,
- columnId,
- layerId: targetLayerId,
- filterOperations,
- dimensionGroups: groups,
- groupId,
- dropType,
- });
+ const dropResult = layerDatasource
+ ? layerDatasourceOnDrop({
+ ...layerDatasourceDropProps,
+ droppedItem,
+ columnId,
+ layerId: targetLayerId,
+ filterOperations,
+ dimensionGroups: groups,
+ groupId,
+ dropType,
+ })
+ : false;
if (dropResult) {
let previousColumn =
typeof droppedItem.column === 'string' ? droppedItem.column : undefined;
@@ -241,6 +246,7 @@ export function LayerPanel(
removeDimension,
layerDatasourceDropProps,
setNextFocusedButtonId,
+ layerDatasource,
]);
const isDimensionPanelOpen = Boolean(activeId);
@@ -340,43 +346,45 @@ export function LayerPanel(
/>
-
{layerDatasource && (
- {
- const newState =
- typeof updater === 'function' ? updater(layerDatasourceState) : updater;
- // Look for removed columns
- const nextPublicAPI = layerDatasource.getPublicAPI({
- state: newState,
- layerId,
- });
- const nextTable = new Set(
- nextPublicAPI.getTableSpec().map(({ columnId }) => columnId)
- );
- const removed = datasourcePublicAPI
- .getTableSpec()
- .map(({ columnId }) => columnId)
- .filter((columnId) => !nextTable.has(columnId));
- let nextVisState = props.visualizationState;
- removed.forEach((columnId) => {
- nextVisState = activeVisualization.removeDimension({
+ <>
+
+ {
+ const newState =
+ typeof updater === 'function' ? updater(layerDatasourceState) : updater;
+ // Look for removed columns
+ const nextPublicAPI = layerDatasource.getPublicAPI({
+ state: newState,
layerId,
- columnId,
- prevState: nextVisState,
- frame: framePublicAPI,
});
- });
+ const nextTable = new Set(
+ nextPublicAPI.getTableSpec().map(({ columnId }) => columnId)
+ );
+ const removed = datasourcePublicAPI
+ .getTableSpec()
+ .map(({ columnId }) => columnId)
+ .filter((columnId) => !nextTable.has(columnId));
+ let nextVisState = props.visualizationState;
+ removed.forEach((columnId) => {
+ nextVisState = activeVisualization.removeDimension({
+ layerId,
+ columnId,
+ prevState: nextVisState,
+ frame: framePublicAPI,
+ });
+ });
- props.updateAll(datasourceId, newState, nextVisState);
- },
- }}
- />
+ props.updateAll(datasourceId, newState, nextVisState);
+ },
+ }}
+ />
+ >
)}
@@ -401,7 +409,6 @@ export function LayerPanel(
: i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', {
defaultMessage: 'Requires field',
});
-
const isOptional = !group.required && !group.suggestedValue;
return (
{group.accessors.map((accessorConfig, accessorIndex) => {
const { columnId } = accessorConfig;
-
return (
{
setActiveDimension({
@@ -478,42 +484,66 @@ export function LayerPanel(
}}
onRemoveClick={(id: string) => {
trackUiEvent('indexpattern_dimension_removed');
- props.updateAll(
- datasourceId,
- layerDatasource.removeColumn({
- layerId,
- columnId: id,
- prevState: layerDatasourceState,
- }),
- activeVisualization.removeDimension({
- layerId,
- columnId: id,
- prevState: props.visualizationState,
- frame: framePublicAPI,
- })
- );
+ if (datasourceId && layerDatasource) {
+ props.updateAll(
+ datasourceId,
+ layerDatasource.removeColumn({
+ layerId,
+ columnId: id,
+ prevState: layerDatasourceState,
+ }),
+ activeVisualization.removeDimension({
+ layerId,
+ columnId: id,
+ prevState: props.visualizationState,
+ frame: framePublicAPI,
+ })
+ );
+ } else {
+ props.updateVisualization(
+ activeVisualization.removeDimension({
+ layerId,
+ columnId: id,
+ prevState: props.visualizationState,
+ frame: framePublicAPI,
+ })
+ );
+ }
removeButtonRef(id);
}}
invalid={
- !layerDatasource.isValidColumn(
+ layerDatasource &&
+ !layerDatasource?.isValidColumn(
layerDatasourceState,
layerId,
columnId
)
}
>
-
+ {layerDatasource ? (
+
+ ) : (
+ <>
+ {activeVisualization?.renderDimensionTrigger?.({
+ columnId,
+ label: columnLabelMap[columnId],
+ hideTooltip,
+ invalid: group.invalid,
+ invalidMessage: group.invalidMessage,
+ })}
+ >
+ )}
@@ -536,7 +566,7 @@ export function LayerPanel(
setActiveDimension({
activeGroup: group,
activeId: id,
- isNew: !group.supportStaticValue,
+ isNew: !group.supportStaticValue && Boolean(layerDatasource),
});
}}
onDrop={onDrop}
@@ -555,22 +585,25 @@ export function LayerPanel(
isFullscreen={isFullscreen}
groupLabel={activeGroup?.groupLabel || ''}
handleClose={() => {
- if (
- layerDatasource.canCloseDimensionEditor &&
- !layerDatasource.canCloseDimensionEditor(layerDatasourceState)
- ) {
- return false;
- }
- if (layerDatasource.updateStateOnCloseDimension) {
- const newState = layerDatasource.updateStateOnCloseDimension({
- state: layerDatasourceState,
- layerId,
- columnId: activeId!,
- });
- if (newState) {
- props.updateDatasource(datasourceId, newState);
+ if (layerDatasource) {
+ if (
+ layerDatasource.canCloseDimensionEditor &&
+ !layerDatasource.canCloseDimensionEditor(layerDatasourceState)
+ ) {
+ return false;
+ }
+ if (layerDatasource.updateStateOnCloseDimension) {
+ const newState = layerDatasource.updateStateOnCloseDimension({
+ state: layerDatasourceState,
+ layerId,
+ columnId: activeId!,
+ });
+ if (newState) {
+ props.updateDatasource(datasourceId, newState);
+ }
}
}
+
setActiveDimension(initialActiveDimensionState);
if (isFullscreen) {
toggleFullscreen();
@@ -579,7 +612,7 @@ export function LayerPanel(
}}
panel={
- {activeGroup && activeId && (
+ {activeGroup && activeId && layerDatasource && (
-
+
-
- color)}
- type={FIXED_PROGRESSION}
- onClick={() => {
- setIsPaletteOpen(!isPaletteOpen);
- }}
- />
-
-
- {
- setIsPaletteOpen(!isPaletteOpen);
- }}
- size="xs"
- flush="both"
- >
- {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', {
- defaultMessage: 'Edit',
- })}
-
- setIsPaletteOpen(!isPaletteOpen)}
- >
- {activePalette && (
- {
- // make sure to always have a list of stops
- if (newPalette.params && !newPalette.params.stops) {
- newPalette.params.stops = displayStops;
- }
- (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor;
- setState({
- ...state,
- palette: newPalette as HeatmapVisualizationState['palette'],
- });
- }}
- />
- )}
-
-
-
-
+
+
+ color)}
+ type={FIXED_PROGRESSION}
+ onClick={() => {
+ setIsPaletteOpen(!isPaletteOpen);
+ }}
+ />
+
+
+ {
+ setIsPaletteOpen(!isPaletteOpen);
+ }}
+ size="xs"
+ flush="both"
+ >
+ {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', {
+ defaultMessage: 'Edit',
+ })}
+
+ setIsPaletteOpen(!isPaletteOpen)}
+ >
+ {activePalette && (
+ {
+ // make sure to always have a list of stops
+ if (newPalette.params && !newPalette.params.stops) {
+ newPalette.params.stops = displayStops;
+ }
+ (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor;
+ setState({
+ ...state,
+ palette: newPalette as HeatmapVisualizationState['palette'],
+ });
+ }}
+ />
+ )}
+
+
+
+
+ >
);
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts
index f3c48bace4a5f..3318b8c30909e 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts
@@ -89,12 +89,13 @@ export function getDropProps(props: GetDropProps) {
) {
const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId];
const targetColumn = state.layers[layerId].columns[columnId];
- const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId];
-
const isSameGroup = groupId === dragging.groupId;
if (isSameGroup) {
- return getDropPropsForSameGroup(targetColumn);
- } else if (filterOperations(sourceColumn)) {
+ return getDropPropsForSameGroup(!targetColumn);
+ }
+ const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId];
+
+ if (filterOperations(sourceColumn)) {
return getDropPropsForCompatibleGroup(
props.dimensionGroups,
dragging.columnId,
@@ -164,8 +165,8 @@ function getDropPropsForField({
return;
}
-function getDropPropsForSameGroup(targetColumn?: GenericIndexPatternColumn): DropProps {
- return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] };
+function getDropPropsForSameGroup(isNew?: boolean): DropProps {
+ return !isNew ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] };
}
function getDropPropsForCompatibleGroup(
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
index f19658d468d5f..6bdd41d8db631 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
@@ -2626,9 +2626,7 @@ describe('IndexPattern Data Source', () => {
expect(
indexPatternDatasource.initializeDimension!(state, 'first', {
columnId: 'newStatic',
- label: 'MyNewColumn',
groupId: 'a',
- dataType: 'number',
})
).toBe(state);
});
@@ -2655,9 +2653,7 @@ describe('IndexPattern Data Source', () => {
expect(
indexPatternDatasource.initializeDimension!(state, 'first', {
columnId: 'newStatic',
- label: 'MyNewColumn',
groupId: 'a',
- dataType: 'number',
staticValue: 0, // use a falsy value to check also this corner case
})
).toEqual({
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
index cf77d1c9c1cc2..d0b644e2bf9b4 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
@@ -230,7 +230,7 @@ export function getIndexPatternDatasource({
});
},
- initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) {
+ initializeDimension(state, layerId, { columnId, groupId, staticValue }) {
const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId];
if (staticValue == null) {
return state;
diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx
index 2c038b0937999..d1f16ac5f9c41 100644
--- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx
+++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx
@@ -22,8 +22,12 @@ import { DEFAULT_PERCENT_DECIMALS } from './constants';
import { PartitionChartsMeta } from './partition_charts_meta';
import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common';
import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types';
-import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components';
-import { PalettePicker } from '../shared_components';
+import {
+ ToolbarPopover,
+ LegendSettingsPopover,
+ useDebouncedValue,
+ PalettePicker,
+} from '../shared_components';
import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values';
import { shouldShowValuesInLegend } from './render_helpers';
@@ -298,14 +302,12 @@ export function DimensionEditor(
}
) {
return (
- <>
- {
- props.setState({ ...props.state, palette: newPalette });
- }}
- />
- >
+ {
+ props.setState({ ...props.state, palette: newPalette });
+ }}
+ />
);
}
diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts
index 4d883c3a27c5e..d2bb7cdbb4344 100644
--- a/x-pack/plugins/lens/public/plugin.ts
+++ b/x-pack/plugins/lens/public/plugin.ts
@@ -33,6 +33,7 @@ import type { NavigationPublicPluginStart } from '../../../../src/plugins/naviga
import type { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public';
import type { GlobalSearchPluginSetup } from '../../global_search/public';
import type { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public';
+import type { EventAnnotationPluginSetup } from '../../../../src/plugins/event_annotation/public';
import type { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public';
import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public';
import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service';
@@ -104,6 +105,7 @@ export interface LensPluginSetupDependencies {
embeddable?: EmbeddableSetup;
visualizations: VisualizationsSetup;
charts: ChartsPluginSetup;
+ eventAnnotation: EventAnnotationPluginSetup;
globalSearch?: GlobalSearchPluginSetup;
usageCollection?: UsageCollectionSetup;
discover?: DiscoverSetup;
@@ -120,6 +122,7 @@ export interface LensPluginStartDependencies {
visualizations: VisualizationsStart;
embeddable: EmbeddableStart;
charts: ChartsPluginStart;
+ eventAnnotation: EventAnnotationPluginSetup;
savedObjectsTagging?: SavedObjectTaggingPluginStart;
presentationUtil: PresentationUtilPluginStart;
dataViewFieldEditor: IndexPatternFieldEditorStart;
@@ -235,6 +238,7 @@ export class LensPlugin {
embeddable,
visualizations,
charts,
+ eventAnnotation,
globalSearch,
usageCollection,
}: LensPluginSetupDependencies
@@ -251,7 +255,8 @@ export class LensPlugin {
charts,
expressions,
fieldFormats,
- plugins.fieldFormats.deserialize
+ plugins.fieldFormats.deserialize,
+ eventAnnotation
);
const visualizationMap = await this.editorFrameService!.loadVisualizations();
@@ -311,7 +316,8 @@ export class LensPlugin {
charts,
expressions,
fieldFormats,
- deps.fieldFormats.deserialize
+ deps.fieldFormats.deserialize,
+ eventAnnotation
),
ensureDefaultDataView(),
]);
@@ -368,7 +374,8 @@ export class LensPlugin {
charts: ChartsPluginSetup,
expressions: ExpressionsServiceSetup,
fieldFormats: FieldFormatsSetup,
- formatFactory: FormatFactory
+ formatFactory: FormatFactory,
+ eventAnnotation: EventAnnotationPluginSetup
) {
const {
DatatableVisualization,
@@ -402,6 +409,7 @@ export class LensPlugin {
charts,
editorFrame: editorFrameSetupInterface,
formatFactory,
+ eventAnnotation,
};
this.indexpatternDatasource.setup(core, dependencies);
this.xyVisualization.setup(core, dependencies);
diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.scss b/x-pack/plugins/lens/public/shared_components/dimension_section.scss
new file mode 100644
index 0000000000000..7781c91785d67
--- /dev/null
+++ b/x-pack/plugins/lens/public/shared_components/dimension_section.scss
@@ -0,0 +1,24 @@
+.lnsDimensionEditorSection {
+ padding-top: $euiSize;
+ padding-bottom: $euiSize;
+}
+
+.lnsDimensionEditorSection:first-child {
+ padding-top: 0;
+}
+
+.lnsDimensionEditorSection:first-child .lnsDimensionEditorSection__border {
+ display: none;
+}
+
+.lnsDimensionEditorSection__border {
+ position: relative;
+ &:before {
+ content: '';
+ position: absolute;
+ top: -$euiSize;
+ right: -$euiSize;
+ left: -$euiSize;
+ border-top: 1px solid $euiColorLightShade;
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.tsx b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx
new file mode 100644
index 0000000000000..d56e08db4b037
--- /dev/null
+++ b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx
@@ -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 { EuiTitle } from '@elastic/eui';
+import React from 'react';
+import './dimension_section.scss';
+
+export const DimensionEditorSection = ({
+ children,
+ title,
+}: {
+ title?: string;
+ children?: React.ReactNode | React.ReactNode[];
+}) => {
+ return (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {children}
+
+ );
+};
diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts
index 6140e54b43dc7..b2428532a72c9 100644
--- a/x-pack/plugins/lens/public/shared_components/index.ts
+++ b/x-pack/plugins/lens/public/shared_components/index.ts
@@ -17,5 +17,6 @@ export { LegendActionPopover } from './legend_action_popover';
export { NameInput } from './name_input';
export { ValueLabelsSettings } from './value_labels_settings';
export { AxisTitleSettings } from './axis_title_settings';
+export { DimensionEditorSection } from './dimension_section';
export * from './static_header';
export * from './vis_label';
diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts
index 56ff89f506c85..959db8ca006fe 100644
--- a/x-pack/plugins/lens/public/state_management/lens_slice.ts
+++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts
@@ -619,30 +619,39 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
return state;
}
- const activeDatasource = datasourceMap[state.activeDatasourceId];
const activeVisualization = visualizationMap[state.visualization.activeId];
-
- const datasourceState = activeDatasource.insertLayer(
- state.datasourceStates[state.activeDatasourceId].state,
- layerId
- );
-
const visualizationState = activeVisualization.appendLayer!(
state.visualization.state,
layerId,
layerType
);
+ const framePublicAPI = {
+ // any better idea to avoid `as`?
+ activeData: state.activeData
+ ? (current(state.activeData) as TableInspectorAdapter)
+ : undefined,
+ datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap),
+ };
+
+ const activeDatasource = datasourceMap[state.activeDatasourceId];
+ const { noDatasource } =
+ activeVisualization
+ .getSupportedLayers(visualizationState, framePublicAPI)
+ .find(({ type }) => type === layerType) || {};
+
+ const datasourceState =
+ !noDatasource && activeDatasource
+ ? activeDatasource.insertLayer(
+ state.datasourceStates[state.activeDatasourceId].state,
+ layerId
+ )
+ : state.datasourceStates[state.activeDatasourceId].state;
+
const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({
datasourceState,
visualizationState,
- framePublicAPI: {
- // any better idea to avoid `as`?
- activeData: state.activeData
- ? (current(state.activeData) as TableInspectorAdapter)
- : undefined,
- datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap),
- },
+ framePublicAPI,
activeVisualization,
activeDatasource,
layerId,
@@ -710,39 +719,49 @@ function addInitialValueIfAvailable({
framePublicAPI: FramePublicAPI;
visualizationState: unknown;
datasourceState: unknown;
- activeDatasource: Datasource;
+ activeDatasource?: Datasource;
activeVisualization: Visualization;
layerId: string;
layerType: string;
columnId?: string;
groupId?: string;
}) {
- const layerInfo = activeVisualization
- .getSupportedLayers(visualizationState, framePublicAPI)
- .find(({ type }) => type === layerType);
+ const { initialDimensions, noDatasource } =
+ activeVisualization
+ .getSupportedLayers(visualizationState, framePublicAPI)
+ .find(({ type }) => type === layerType) || {};
- if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) {
+ if (initialDimensions) {
const info = groupId
- ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId)
- : // pick the first available one if not passed
- layerInfo.initialDimensions[0];
+ ? initialDimensions.find(({ groupId: id }) => id === groupId)
+ : initialDimensions[0]; // pick the first available one if not passed
if (info) {
- return {
- activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, {
- ...info,
- columnId: columnId || info.columnId,
- }),
- activeVisualizationState: activeVisualization.setDimension({
- groupId: info.groupId,
- layerId,
- columnId: columnId || info.columnId,
- prevState: visualizationState,
- frame: framePublicAPI,
- }),
- };
+ const activeVisualizationState = activeVisualization.setDimension({
+ groupId: info.groupId,
+ layerId,
+ columnId: columnId || info.columnId,
+ prevState: visualizationState,
+ frame: framePublicAPI,
+ });
+
+ if (!noDatasource && activeDatasource?.initializeDimension) {
+ return {
+ activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, {
+ ...info,
+ columnId: columnId || info.columnId,
+ }),
+ activeVisualizationState,
+ };
+ } else {
+ return {
+ activeDatasourceState: datasourceState,
+ activeVisualizationState,
+ };
+ }
}
}
+
return {
activeDatasourceState: datasourceState,
activeVisualizationState: visualizationState,
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index 9bea94bd723d3..cfa23320dc561 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -198,6 +198,12 @@ interface ChartSettings {
};
}
+export type GetDropProps = DatasourceDimensionDropProps & {
+ groupId: string;
+ dragging: DragContextState['dragging'];
+ prioritizedOperation?: string;
+};
+
/**
* Interface for the datasource registry
*/
@@ -227,10 +233,8 @@ export interface Datasource {
layerId: string,
value: {
columnId: string;
- label: string;
- dataType: string;
- staticValue?: unknown;
groupId: string;
+ staticValue?: unknown;
}
) => T;
@@ -251,11 +255,7 @@ export interface Datasource {
props: DatasourceLayerPanelProps
) => ((cleanupElement: Element) => void) | void;
getDropProps: (
- props: DatasourceDimensionDropProps & {
- groupId: string;
- dragging: DragContextState['dragging'];
- prioritizedOperation?: string;
- }
+ props: GetDropProps
) => { dropTypes: DropType[]; nextLabel?: string } | undefined;
onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string };
/**
@@ -585,6 +585,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
supportStaticValue?: boolean;
paramEditorCustomProps?: ParamEditorCustomProps;
supportFieldFormat?: boolean;
+ labels?: { buttonAriaLabel: string; buttonLabel: string };
};
interface VisualizationDimensionChangeProps {
@@ -786,14 +787,13 @@ export interface Visualization {
type: LayerType;
label: string;
icon?: IconType;
+ noDatasource?: boolean;
disabled?: boolean;
toolTipContent?: string;
initialDimensions?: Array<{
- groupId: string;
columnId: string;
- dataType: string;
- label: string;
- staticValue: unknown;
+ groupId: string;
+ staticValue?: unknown;
}>;
}>;
getLayerType: (layerId: string, state?: T) => LayerType | undefined;
@@ -858,7 +858,20 @@ export interface Visualization {
domElement: Element,
props: VisualizationDimensionEditorProps
) => ((cleanupElement: Element) => void) | void;
-
+ /**
+ * Renders dimension trigger. Used only for noDatasource layers
+ */
+ renderDimensionTrigger?: (props: {
+ columnId: string;
+ label: string;
+ hideTooltip?: boolean;
+ invalid?: boolean;
+ invalidMessage?: string;
+ }) => JSX.Element | null;
+ /**
+ * Creates map of columns ids and unique lables. Used only for noDatasource layers
+ */
+ getUniqueLabels?: (state: T) => Record;
/**
* The frame will call this function on all visualizations at different times. The
* main use cases where visualization suggestions are requested are:
diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx
index e1885fafab5e0..1770bac893b67 100644
--- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx
+++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx
@@ -399,22 +399,16 @@ export const getGaugeVisualization = ({
{
groupId: 'min',
columnId: generateId(),
- dataType: 'number',
- label: 'minAccessor',
staticValue: minValue,
},
{
groupId: 'max',
columnId: generateId(),
- dataType: 'number',
- label: 'maxAccessor',
staticValue: maxValue,
},
{
groupId: 'goal',
columnId: generateId(),
- dataType: 'number',
- label: 'goalAccessor',
staticValue: goalValue,
},
]
diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap
index 504a553c5a631..fdde8eb6ad3f2 100644
--- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap
+++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap
@@ -1,5 +1,218 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`xy_expression XYChart component annotations should render basic annotation 1`] = `
+
+ }
+ markerBody={
+
+ }
+ markerPosition="top"
+ style={
+ Object {
+ "line": Object {
+ "dash": undefined,
+ "opacity": 1,
+ "stroke": "#f04e98",
+ "strokeWidth": 1,
+ },
+ }
+ }
+/>
+`;
+
+exports[`xy_expression XYChart component annotations should render grouped annotations preserving the shared styles 1`] = `
+
+ }
+ markerBody={
+
+ }
+ markerPosition="top"
+ style={
+ Object {
+ "line": Object {
+ "dash": Array [
+ 9,
+ 3,
+ ],
+ "opacity": 1,
+ "stroke": "red",
+ "strokeWidth": 3,
+ },
+ }
+ }
+/>
+`;
+
+exports[`xy_expression XYChart component annotations should render grouped annotations with default styles 1`] = `
+
+ }
+ markerBody={
+
+ }
+ markerPosition="top"
+ style={
+ Object {
+ "line": Object {
+ "dash": undefined,
+ "opacity": 1,
+ "stroke": "#f04e98",
+ "strokeWidth": 1,
+ },
+ }
+ }
+/>
+`;
+
+exports[`xy_expression XYChart component annotations should render simplified annotation when hide is true 1`] = `
+
+ }
+ markerBody={
+
+ }
+ markerPosition="top"
+ style={
+ Object {
+ "line": Object {
+ "dash": undefined,
+ "opacity": 1,
+ "stroke": "#f04e98",
+ "strokeWidth": 1,
+ },
+ }
+ }
+/>
+`;
+
exports[`xy_expression XYChart component it renders area 1`] = `
& {
+ formatFactory: FormatFactory;
+ paletteService: PaletteRegistry;
+ }
+) => {
+ const { state, setState, layerId, accessor } = props;
+ const isHorizontal = isHorizontalChart(state.layers);
+
+ const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({
+ value: state,
+ onChange: setState,
+ });
+
+ const index = localState.layers.findIndex((l) => l.layerId === layerId);
+ const localLayer = localState.layers.find(
+ (l) => l.layerId === layerId
+ ) as XYAnnotationLayerConfig;
+
+ const currentAnnotations = localLayer.annotations?.find((c) => c.id === accessor);
+
+ const setAnnotations = useCallback(
+ (annotations: Partial | undefined) => {
+ if (annotations == null) {
+ return;
+ }
+ const newConfigs = [...(localLayer.annotations || [])];
+ const existingIndex = newConfigs.findIndex((c) => c.id === accessor);
+ if (existingIndex !== -1) {
+ newConfigs[existingIndex] = { ...newConfigs[existingIndex], ...annotations };
+ } else {
+ return; // that should never happen because annotations are created before annotations panel is opened
+ }
+ setLocalState(updateLayer(localState, { ...localLayer, annotations: newConfigs }, index));
+ },
+ [accessor, index, localState, localLayer, setLocalState]
+ );
+
+ return (
+ <>
+
+ {
+ if (date) {
+ setAnnotations({
+ key: {
+ ...(currentAnnotations?.key || { type: 'point_in_time' }),
+ timestamp: date.toISOString(),
+ },
+ });
+ }
+ }}
+ label={i18n.translate('xpack.lens.xyChart.annotationDate', {
+ defaultMessage: 'Annotation date',
+ })}
+ />
+
+
+ {
+ setAnnotations({ label: value });
+ }}
+ />
+
+
+
+ setAnnotations({ isHidden: ev.target.checked })}
+ />
+
+ >
+ );
+};
+
+const ConfigPanelDatePicker = ({
+ value,
+ label,
+ onChange,
+}: {
+ value: moment.Moment;
+ label: string;
+ onChange: (val: moment.Moment | null) => void;
+}) => {
+ return (
+
+
+
+ );
+};
+
+const ConfigPanelHideSwitch = ({
+ value,
+ onChange,
+}: {
+ value: boolean;
+ onChange: (event: EuiSwitchEvent) => void;
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss
new file mode 100644
index 0000000000000..fc2b1204bb1d0
--- /dev/null
+++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss
@@ -0,0 +1,37 @@
+.lnsXyDecorationRotatedWrapper {
+ display: inline-block;
+ overflow: hidden;
+ line-height: 1.5;
+
+ .lnsXyDecorationRotatedWrapper__label {
+ display: inline-block;
+ white-space: nowrap;
+ transform: translate(0, 100%) rotate(-90deg);
+ transform-origin: 0 0;
+
+ &::after {
+ content: '';
+ float: left;
+ margin-top: 100%;
+ }
+ }
+}
+
+.lnsXyAnnotationNumberIcon {
+ border-radius: $euiSize;
+ min-width: $euiSize;
+ height: $euiSize;
+ background-color: currentColor;
+}
+
+.lnsXyAnnotationNumberIcon__text {
+ font-weight: 500;
+ font-size: 9px;
+ letter-spacing: -.5px;
+ line-height: 11px;
+}
+
+.lnsXyAnnotationIcon_rotate90 {
+ transform: rotate(45deg);
+ transform-origin: center;
+}
diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx
new file mode 100644
index 0000000000000..c36488f29d238
--- /dev/null
+++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx
@@ -0,0 +1,233 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import './expression.scss';
+import React from 'react';
+import { snakeCase } from 'lodash';
+import {
+ AnnotationDomainType,
+ AnnotationTooltipFormatter,
+ LineAnnotation,
+ Position,
+} from '@elastic/charts';
+import type { FieldFormat } from 'src/plugins/field_formats/common';
+import type { EventAnnotationArgs } from 'src/plugins/event_annotation/common';
+import moment from 'moment';
+import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public';
+import type { AnnotationLayerArgs } from '../../../common/expressions';
+import { hasIcon } from '../xy_config_panel/shared/icon_select';
+import {
+ mapVerticalToHorizontalPlacement,
+ LINES_MARKER_SIZE,
+ MarkerBody,
+ Marker,
+ AnnotationIcon,
+} from '../annotations_helpers';
+
+const getRoundedTimestamp = (timestamp: number, firstTimestamp?: number, minInterval?: number) => {
+ if (!firstTimestamp || !minInterval) {
+ return timestamp;
+ }
+ return timestamp - ((timestamp - firstTimestamp) % minInterval);
+};
+
+export interface AnnotationsProps {
+ groupedAnnotations: CollectiveConfig[];
+ formatter?: FieldFormat;
+ isHorizontal: boolean;
+ paddingMap: Partial>;
+ hide?: boolean;
+ minInterval?: number;
+ isBarChart?: boolean;
+}
+
+interface CollectiveConfig extends EventAnnotationArgs {
+ roundedTimestamp: number;
+ axisMode: 'bottom';
+ customTooltipDetails?: AnnotationTooltipFormatter | undefined;
+}
+
+const groupVisibleConfigsByInterval = (
+ layers: AnnotationLayerArgs[],
+ minInterval?: number,
+ firstTimestamp?: number
+) => {
+ return layers
+ .flatMap(({ annotations }) => annotations.filter((a) => !a.isHidden))
+ .reduce>((acc, current) => {
+ const roundedTimestamp = getRoundedTimestamp(
+ moment(current.time).valueOf(),
+ firstTimestamp,
+ minInterval
+ );
+ return {
+ ...acc,
+ [roundedTimestamp]: acc[roundedTimestamp] ? [...acc[roundedTimestamp], current] : [current],
+ };
+ }, {});
+};
+
+const createCustomTooltipDetails =
+ (
+ config: EventAnnotationArgs[],
+ formatter?: FieldFormat
+ ): AnnotationTooltipFormatter | undefined =>
+ () => {
+ return (
+
+ {config.map(({ icon, label, time, color }) => (
+
+
+ {hasIcon(icon) && (
+
+
+
+ )}
+ {label}
+
+
{formatter?.convert(time) || String(time)}
+
+ ))}
+
+ );
+ };
+
+function getCommonProperty(
+ configArr: EventAnnotationArgs[],
+ propertyName: K,
+ fallbackValue: T
+) {
+ const firstStyle = configArr[0][propertyName];
+ if (configArr.every((config) => firstStyle === config[propertyName])) {
+ return firstStyle;
+ }
+ return fallbackValue;
+}
+
+const getCommonStyles = (configArr: EventAnnotationArgs[]) => {
+ return {
+ color: getCommonProperty(
+ configArr,
+ 'color',
+ defaultAnnotationColor
+ ),
+ lineWidth: getCommonProperty(configArr, 'lineWidth', 1),
+ lineStyle: getCommonProperty(configArr, 'lineStyle', 'solid'),
+ textVisibility: getCommonProperty(configArr, 'textVisibility', false),
+ };
+};
+
+export const getAnnotationsGroupedByInterval = (
+ layers: AnnotationLayerArgs[],
+ minInterval?: number,
+ firstTimestamp?: number,
+ formatter?: FieldFormat
+) => {
+ const visibleGroupedConfigs = groupVisibleConfigsByInterval(layers, minInterval, firstTimestamp);
+ let collectiveConfig: CollectiveConfig;
+ return Object.entries(visibleGroupedConfigs).map(([roundedTimestamp, configArr]) => {
+ collectiveConfig = {
+ ...configArr[0],
+ roundedTimestamp: Number(roundedTimestamp),
+ axisMode: 'bottom',
+ };
+ if (configArr.length > 1) {
+ const commonStyles = getCommonStyles(configArr);
+ collectiveConfig = {
+ ...collectiveConfig,
+ ...commonStyles,
+ icon: String(configArr.length),
+ customTooltipDetails: createCustomTooltipDetails(configArr, formatter),
+ };
+ }
+ return collectiveConfig;
+ });
+};
+
+export const Annotations = ({
+ groupedAnnotations,
+ formatter,
+ isHorizontal,
+ paddingMap,
+ hide,
+ minInterval,
+ isBarChart,
+}: AnnotationsProps) => {
+ return (
+ <>
+ {groupedAnnotations.map((annotation) => {
+ const markerPositionVertical = Position.Top;
+ const markerPosition = isHorizontal
+ ? mapVerticalToHorizontalPlacement(markerPositionVertical)
+ : markerPositionVertical;
+ const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE;
+ const id = snakeCase(annotation.label);
+ const { roundedTimestamp, time: exactTimestamp } = annotation;
+ const isGrouped = Boolean(annotation.customTooltipDetails);
+ const header =
+ formatter?.convert(isGrouped ? roundedTimestamp : exactTimestamp) ||
+ moment(isGrouped ? roundedTimestamp : exactTimestamp).toISOString();
+ const strokeWidth = annotation.lineWidth || 1;
+ return (
+
+ ) : undefined
+ }
+ markerBody={
+ !hide ? (
+
+ ) : undefined
+ }
+ markerPosition={markerPosition}
+ dataValues={[
+ {
+ dataValue: moment(
+ isBarChart && minInterval ? roundedTimestamp + minInterval / 2 : roundedTimestamp
+ ).valueOf(),
+ header,
+ details: annotation.label,
+ },
+ ]}
+ customTooltipDetails={annotation.customTooltipDetails}
+ style={{
+ line: {
+ strokeWidth,
+ stroke: annotation.color || defaultAnnotationColor,
+ dash:
+ annotation.lineStyle === 'dashed'
+ ? [strokeWidth * 3, strokeWidth]
+ : annotation.lineStyle === 'dotted'
+ ? [strokeWidth, strokeWidth]
+ : undefined,
+ opacity: 1,
+ },
+ }}
+ />
+ );
+ })}
+ >
+ );
+};
diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts
new file mode 100644
index 0000000000000..fbf13db7fa7a5
--- /dev/null
+++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts
@@ -0,0 +1,210 @@
+/*
+ * 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 { FramePublicAPI } from '../../types';
+import { getStaticDate } from './helpers';
+
+describe('annotations helpers', () => {
+ describe('getStaticDate', () => {
+ it('should return `now` value on when nothing is configured', () => {
+ jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-08T11:01:58.135Z').valueOf());
+ expect(getStaticDate([], undefined)).toBe('2022-04-08T11:01:58.135Z');
+ });
+ it('should return `now` value on when there is no active data', () => {
+ expect(
+ getStaticDate(
+ [
+ {
+ layerId: 'layerId',
+ accessors: ['b'],
+ seriesType: 'bar_stacked',
+ layerType: 'data',
+ xAccessor: 'a',
+ },
+ ],
+ undefined
+ )
+ ).toBe('2022-04-08T11:01:58.135Z');
+ });
+
+ it('should return timestamp value for single active data point', () => {
+ const activeData = {
+ layerId: {
+ type: 'datatable',
+ rows: [
+ {
+ a: 1646002800000,
+ b: 1050,
+ },
+ ],
+ columns: [
+ {
+ id: 'a',
+ name: 'order_date per week',
+ meta: { type: 'date' },
+ },
+ {
+ id: 'b',
+ name: 'Count of records',
+ meta: { type: 'number', params: { id: 'number' } },
+ },
+ ],
+ },
+ };
+ expect(
+ getStaticDate(
+ [
+ {
+ layerId: 'layerId',
+ accessors: ['b'],
+ seriesType: 'bar_stacked',
+ layerType: 'data',
+ xAccessor: 'a',
+ },
+ ],
+ activeData as FramePublicAPI['activeData']
+ )
+ ).toBe('2022-02-27T23:00:00.000Z');
+ });
+
+ it('should correctly calculate middle value for active data', () => {
+ const activeData = {
+ layerId: {
+ type: 'datatable',
+ rows: [
+ {
+ a: 1648206000000,
+ b: 19,
+ },
+ {
+ a: 1648249200000,
+ b: 73,
+ },
+ {
+ a: 1648292400000,
+ b: 69,
+ },
+ {
+ a: 1648335600000,
+ b: 7,
+ },
+ ],
+ columns: [
+ {
+ id: 'a',
+ name: 'order_date per week',
+ meta: { type: 'date' },
+ },
+ {
+ id: 'b',
+ name: 'Count of records',
+ meta: { type: 'number', params: { id: 'number' } },
+ },
+ ],
+ },
+ };
+ expect(
+ getStaticDate(
+ [
+ {
+ layerId: 'layerId',
+ accessors: ['b'],
+ seriesType: 'bar_stacked',
+ layerType: 'data',
+ xAccessor: 'a',
+ },
+ ],
+ activeData as FramePublicAPI['activeData']
+ )
+ ).toBe('2022-03-26T05:00:00.000Z');
+ });
+
+ it('should calculate middle date point correctly for multiple layers', () => {
+ const activeData = {
+ layerId: {
+ type: 'datatable',
+ rows: [
+ {
+ a: 1648206000000,
+ b: 19,
+ },
+ {
+ a: 1648249200000,
+ b: 73,
+ },
+ {
+ a: 1648292400000,
+ b: 69,
+ },
+ {
+ a: 1648335600000,
+ b: 7,
+ },
+ ],
+ columns: [
+ {
+ id: 'a',
+ name: 'order_date per week',
+ meta: { type: 'date' },
+ },
+ {
+ id: 'b',
+ name: 'Count of records',
+ meta: { type: 'number', params: { id: 'number' } },
+ },
+ ],
+ },
+ layerId2: {
+ type: 'datatable',
+ rows: [
+ {
+ d: 1548206000000,
+ c: 19,
+ },
+ {
+ d: 1548249200000,
+ c: 73,
+ },
+ ],
+ columns: [
+ {
+ id: 'd',
+ name: 'order_date per week',
+ meta: { type: 'date' },
+ },
+ {
+ id: 'c',
+ name: 'Count of records',
+ meta: { type: 'number', params: { id: 'number' } },
+ },
+ ],
+ },
+ };
+ expect(
+ getStaticDate(
+ [
+ {
+ layerId: 'layerId',
+ accessors: ['b'],
+ seriesType: 'bar_stacked',
+ layerType: 'data',
+ xAccessor: 'a',
+ },
+ {
+ layerId: 'layerId2',
+ accessors: ['c'],
+ seriesType: 'bar_stacked',
+ layerType: 'data',
+ xAccessor: 'd',
+ },
+ ],
+ activeData as FramePublicAPI['activeData']
+ )
+ ).toBe('2020-08-24T12:06:40.000Z');
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx
new file mode 100644
index 0000000000000..321090c94241a
--- /dev/null
+++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx
@@ -0,0 +1,240 @@
+/*
+ * 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 moment from 'moment';
+import { layerTypes } from '../../../common';
+import type {
+ XYDataLayerConfig,
+ XYAnnotationLayerConfig,
+ XYLayerConfig,
+} from '../../../common/expressions';
+import type { FramePublicAPI, Visualization } from '../../types';
+import { isHorizontalChart } from '../state_helpers';
+import type { XYState } from '../types';
+import {
+ checkScaleOperation,
+ getAnnotationsLayers,
+ getAxisName,
+ getDataLayers,
+ isAnnotationsLayer,
+} from '../visualization_helpers';
+import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations';
+import { generateId } from '../../id_generator';
+import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public';
+import { defaultAnnotationLabel } from './config_panel';
+
+const MAX_DATE = 8640000000000000;
+const MIN_DATE = -8640000000000000;
+
+export function getStaticDate(
+ dataLayers: XYDataLayerConfig[],
+ activeData: FramePublicAPI['activeData']
+) {
+ const fallbackValue = moment().toISOString();
+
+ const dataLayersId = dataLayers.map(({ layerId }) => layerId);
+ if (
+ !activeData ||
+ Object.entries(activeData)
+ .filter(([key]) => dataLayersId.includes(key))
+ .every(([, { rows }]) => !rows || !rows.length)
+ ) {
+ return fallbackValue;
+ }
+
+ const minDate = dataLayersId.reduce((acc, lId) => {
+ const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!;
+ const firstTimestamp = activeData[lId]?.rows?.[0]?.[xAccessor];
+ return firstTimestamp && firstTimestamp < acc ? firstTimestamp : acc;
+ }, MAX_DATE);
+
+ const maxDate = dataLayersId.reduce((acc, lId) => {
+ const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!;
+ const lastTimestamp = activeData[lId]?.rows?.[activeData?.[lId]?.rows?.length - 1]?.[xAccessor];
+ return lastTimestamp && lastTimestamp > acc ? lastTimestamp : acc;
+ }, MIN_DATE);
+ const middleDate = (minDate + maxDate) / 2;
+ return moment(middleDate).toISOString();
+}
+
+export const getAnnotationsSupportedLayer = (
+ state?: XYState,
+ frame?: Pick
+) => {
+ const dataLayers = getDataLayers(state?.layers || []);
+
+ const hasDateHistogram = Boolean(
+ dataLayers.length &&
+ dataLayers.every(
+ (dataLayer) =>
+ dataLayer.xAccessor &&
+ checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer)
+ )
+ );
+ const initialDimensions =
+ state && hasDateHistogram
+ ? [
+ {
+ groupId: 'xAnnotations',
+ columnId: generateId(),
+ },
+ ]
+ : undefined;
+
+ return {
+ type: layerTypes.ANNOTATIONS,
+ label: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabel', {
+ defaultMessage: 'Annotations',
+ }),
+ icon: LensIconChartBarAnnotations,
+ disabled: !hasDateHistogram,
+ toolTipContent: !hasDateHistogram
+ ? i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', {
+ defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.',
+ })
+ : undefined,
+ initialDimensions,
+ noDatasource: true,
+ };
+};
+
+export const setAnnotationsDimension: Visualization['setDimension'] = ({
+ prevState,
+ layerId,
+ columnId,
+ previousColumn,
+ frame,
+}) => {
+ const foundLayer = prevState.layers.find((l) => l.layerId === layerId);
+ if (!foundLayer || !isAnnotationsLayer(foundLayer)) {
+ return prevState;
+ }
+ const dataLayers = getDataLayers(prevState.layers);
+ const newLayer = { ...foundLayer } as XYAnnotationLayerConfig;
+
+ const hasConfig = newLayer.annotations?.some(({ id }) => id === columnId);
+ const previousConfig = previousColumn
+ ? newLayer.annotations?.find(({ id }) => id === previousColumn)
+ : false;
+ if (!hasConfig) {
+ const newTimestamp = getStaticDate(dataLayers, frame?.activeData);
+ newLayer.annotations = [
+ ...(newLayer.annotations || []),
+ {
+ label: defaultAnnotationLabel,
+ key: {
+ type: 'point_in_time',
+ timestamp: newTimestamp,
+ },
+ icon: 'triangle',
+ ...previousConfig,
+ id: columnId,
+ },
+ ];
+ }
+ return {
+ ...prevState,
+ layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)),
+ };
+};
+
+export const getAnnotationsAccessorColorConfig = (layer: XYAnnotationLayerConfig) => {
+ return layer.annotations.map((annotation) => {
+ return {
+ columnId: annotation.id,
+ triggerIcon: annotation.isHidden ? ('invisible' as const) : ('color' as const),
+ color: annotation?.color || defaultAnnotationColor,
+ };
+ });
+};
+
+export const getAnnotationsConfiguration = ({
+ state,
+ frame,
+ layer,
+}: {
+ state: XYState;
+ frame: FramePublicAPI;
+ layer: XYAnnotationLayerConfig;
+}) => {
+ const dataLayers = getDataLayers(state.layers);
+
+ const hasDateHistogram = Boolean(
+ dataLayers.length &&
+ dataLayers.every(
+ (dataLayer) =>
+ dataLayer.xAccessor &&
+ checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer)
+ )
+ );
+
+ const groupLabel = getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) });
+
+ const emptyButtonLabels = {
+ buttonAriaLabel: i18n.translate('xpack.lens.indexPattern.addColumnAriaLabelClick', {
+ defaultMessage: 'Add an annotation to {groupLabel}',
+ values: { groupLabel },
+ }),
+ buttonLabel: i18n.translate('xpack.lens.configure.emptyConfigClick', {
+ defaultMessage: 'Add an annotation',
+ }),
+ };
+
+ return {
+ groups: [
+ {
+ groupId: 'xAnnotations',
+ groupLabel,
+ accessors: getAnnotationsAccessorColorConfig(layer),
+ dataTestSubj: 'lnsXY_xAnnotationsPanel',
+ invalid: !hasDateHistogram,
+ invalidMessage: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', {
+ defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.',
+ }),
+ required: false,
+ requiresPreviousColumnOnDuplicate: true,
+ supportsMoreColumns: true,
+ supportFieldFormat: false,
+ enableDimensionEditor: true,
+ filterOperations: () => false,
+ labels: emptyButtonLabels,
+ },
+ ],
+ };
+};
+
+export const getUniqueLabels = (layers: XYLayerConfig[]) => {
+ const annotationLayers = getAnnotationsLayers(layers);
+ const columnLabelMap = {} as Record;
+ const counts = {} as Record;
+
+ const makeUnique = (label: string) => {
+ let uniqueLabel = label;
+
+ while (counts[uniqueLabel] >= 0) {
+ const num = ++counts[uniqueLabel];
+ uniqueLabel = i18n.translate('xpack.lens.uniqueLabel', {
+ defaultMessage: '{label} [{num}]',
+ values: { label, num },
+ });
+ }
+
+ counts[uniqueLabel] = 0;
+ return uniqueLabel;
+ };
+
+ annotationLayers.forEach((layer) => {
+ if (!layer.annotations) {
+ return;
+ }
+ layer.annotations.forEach((l) => {
+ columnLabelMap[l.id] = makeUnique(l.label);
+ });
+ });
+ return columnLabelMap;
+};
diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx
new file mode 100644
index 0000000000000..ddbdfc91f4a3e
--- /dev/null
+++ b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx
@@ -0,0 +1,253 @@
+/*
+ * 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 './expression_reference_lines.scss';
+import React from 'react';
+import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui';
+import { Position } from '@elastic/charts';
+import classnames from 'classnames';
+import type { IconPosition, YAxisMode, YConfig } from '../../common/expressions';
+import { hasIcon } from './xy_config_panel/shared/icon_select';
+import { annotationsIconSet } from './annotations/config_panel/icon_set';
+
+export const LINES_MARKER_SIZE = 20;
+
+export const computeChartMargins = (
+ referenceLinePaddings: Partial>,
+ labelVisibility: Partial>,
+ titleVisibility: Partial>,
+ axesMap: Record<'left' | 'right', unknown>,
+ isHorizontal: boolean
+) => {
+ const result: Partial> = {};
+ if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) {
+ const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom';
+ result[placement] = referenceLinePaddings.bottom;
+ }
+ if (
+ referenceLinePaddings.left &&
+ (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft))
+ ) {
+ const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left';
+ result[placement] = referenceLinePaddings.left;
+ }
+ if (
+ referenceLinePaddings.right &&
+ (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight))
+ ) {
+ const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right';
+ result[placement] = referenceLinePaddings.right;
+ }
+ // there's no top axis, so just check if a margin has been computed
+ if (referenceLinePaddings.top) {
+ const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top';
+ result[placement] = referenceLinePaddings.top;
+ }
+ return result;
+};
+
+// Note: it does not take into consideration whether the reference line is in view or not
+
+export const getLinesCausedPaddings = (
+ visualConfigs: Array<
+ Pick | undefined
+ >,
+ axesMap: Record<'left' | 'right', unknown>
+) => {
+ // collect all paddings for the 4 axis: if any text is detected double it.
+ const paddings: Partial> = {};
+ const icons: Partial> = {};
+ visualConfigs?.forEach((config) => {
+ if (!config) {
+ return;
+ }
+ const { axisMode, icon, iconPosition, textVisibility } = config;
+ if (axisMode && (hasIcon(icon) || textVisibility)) {
+ const placement = getBaseIconPlacement(iconPosition, axesMap, axisMode);
+ paddings[placement] = Math.max(
+ paddings[placement] || 0,
+ LINES_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text
+ );
+ icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0);
+ }
+ });
+ // post-process the padding based on the icon presence:
+ // if no icon is present for the placement, just reduce the padding
+ (Object.keys(paddings) as Position[]).forEach((placement) => {
+ if (!icons[placement]) {
+ paddings[placement] = LINES_MARKER_SIZE;
+ }
+ });
+ return paddings;
+};
+
+export function mapVerticalToHorizontalPlacement(placement: Position) {
+ switch (placement) {
+ case Position.Top:
+ return Position.Right;
+ case Position.Bottom:
+ return Position.Left;
+ case Position.Left:
+ return Position.Bottom;
+ case Position.Right:
+ return Position.Top;
+ }
+}
+
+// if there's just one axis, put it on the other one
+// otherwise use the same axis
+// this function assume the chart is vertical
+export function getBaseIconPlacement(
+ iconPosition: IconPosition | undefined,
+ axesMap?: Record,
+ axisMode?: YAxisMode
+) {
+ if (iconPosition === 'auto') {
+ if (axisMode === 'bottom') {
+ return Position.Top;
+ }
+ if (axesMap) {
+ if (axisMode === 'left') {
+ return axesMap.right ? Position.Left : Position.Right;
+ }
+ return axesMap.left ? Position.Right : Position.Left;
+ }
+ }
+
+ if (iconPosition === 'left') {
+ return Position.Left;
+ }
+ if (iconPosition === 'right') {
+ return Position.Right;
+ }
+ if (iconPosition === 'below') {
+ return Position.Bottom;
+ }
+ return Position.Top;
+}
+
+export function MarkerBody({
+ label,
+ isHorizontal,
+}: {
+ label: string | undefined;
+ isHorizontal: boolean;
+}) {
+ if (!label) {
+ return null;
+ }
+ if (isHorizontal) {
+ return (
+
+ {label}
+
+ );
+ }
+ return (
+
+ );
+}
+
+const isNumericalString = (value: string) => !isNaN(Number(value));
+
+function NumberIcon({ number }: { number: number }) {
+ return (
+
+
+ {number < 10 ? number : `9+`}
+
+
+ );
+}
+
+interface MarkerConfig {
+ axisMode?: YAxisMode;
+ icon?: string;
+ textVisibility?: boolean;
+ iconPosition?: IconPosition;
+}
+
+export const AnnotationIcon = ({
+ type,
+ rotateClassName = '',
+ isHorizontal,
+ renderedInChart,
+ ...rest
+}: {
+ type: string;
+ rotateClassName?: string;
+ isHorizontal?: boolean;
+ renderedInChart?: boolean;
+} & EuiIconProps) => {
+ if (isNumericalString(type)) {
+ return ;
+ }
+ const iconConfig = annotationsIconSet.find((i) => i.value === type);
+ if (!iconConfig) {
+ return null;
+ }
+ return (
+
+ );
+};
+
+export function Marker({
+ config,
+ isHorizontal,
+ hasReducedPadding,
+ label,
+ rotateClassName,
+}: {
+ config: MarkerConfig;
+ isHorizontal: boolean;
+ hasReducedPadding: boolean;
+ label?: string;
+ rotateClassName?: string;
+}) {
+ if (hasIcon(config.icon)) {
+ return (
+
+ );
+ }
+
+ // if there's some text, check whether to show it as marker, or just show some padding for the icon
+ if (config.textVisibility) {
+ if (hasReducedPadding) {
+ return ;
+ }
+ return ;
+ }
+ return null;
+}
diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts
index 82c1106e72a08..f8d5805279a2e 100644
--- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts
@@ -13,7 +13,9 @@ import type { AccessorConfig, FramePublicAPI } from '../types';
import { getColumnToLabelMap } from './state_helpers';
import { FormatFactory, LayerType } from '../../common';
import type { XYLayerConfig } from '../../common/expressions';
-import { isDataLayer, isReferenceLayer } from './visualization_helpers';
+import { isReferenceLayer, isAnnotationsLayer } from './visualization_helpers';
+import { getAnnotationsAccessorColorConfig } from './annotations/helpers';
+import { getReferenceLineAccessorColorConfig } from './reference_line_helpers';
const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object';
@@ -42,15 +44,13 @@ export function getColorAssignments(
): ColorAssignments {
const layersPerPalette: Record = {};
- layers
- .filter((layer) => isDataLayer(layer))
- .forEach((layer) => {
- const palette = layer.palette?.name || 'default';
- if (!layersPerPalette[palette]) {
- layersPerPalette[palette] = [];
- }
- layersPerPalette[palette].push(layer);
- });
+ layers.forEach((layer) => {
+ const palette = layer.palette?.name || 'default';
+ if (!layersPerPalette[palette]) {
+ layersPerPalette[palette] = [];
+ }
+ layersPerPalette[palette].push(layer);
+ });
return mapValues(layersPerPalette, (paletteLayers) => {
const seriesPerLayer = paletteLayers.map((layer, layerIndex) => {
@@ -102,17 +102,6 @@ export function getColorAssignments(
});
}
-const getReferenceLineAccessorColorConfig = (layer: XYLayerConfig) => {
- return layer.accessors.map((accessor) => {
- const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor);
- return {
- columnId: accessor,
- triggerIcon: 'color' as const,
- color: currentYConfig?.color || defaultReferenceLineColor,
- };
- });
-};
-
export function getAccessorColorConfig(
colorAssignments: ColorAssignments,
frame: Pick,
@@ -122,7 +111,9 @@ export function getAccessorColorConfig(
if (isReferenceLayer(layer)) {
return getReferenceLineAccessorColorConfig(layer);
}
-
+ if (isAnnotationsLayer(layer)) {
+ return getAnnotationsAccessorColorConfig(layer);
+ }
const layerContainsSplits = Boolean(layer.splitAccessor);
const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' };
const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount;
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
index 654a0f1b94a14..03a180cc20a08 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx
@@ -20,12 +20,13 @@ import {
HorizontalAlignment,
VerticalAlignment,
LayoutDirection,
+ LineAnnotation,
} from '@elastic/charts';
import { PaletteOutput } from 'src/plugins/charts/public';
import { calculateMinInterval, XYChart, XYChartRenderProps } from './expression';
import type { LensMultiTable } from '../../common';
import { layerTypes } from '../../common';
-import { xyChart } from '../../common/expressions';
+import { AnnotationLayerArgs, xyChart } from '../../common/expressions';
import {
dataLayerConfig,
legendConfig,
@@ -41,12 +42,14 @@ import {
} from '../../common/expressions';
import { Datatable, DatatableRow } from '../../../../../src/plugins/expressions/public';
import React from 'react';
-import { shallow } from 'enzyme';
+import { mount, shallow } from 'enzyme';
import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import { XyEndzones } from './x_domain';
+import { eventAnnotationServiceMock } from '../../../../../src/plugins/event_annotation/public/mocks';
+import { EventAnnotationOutput } from 'src/plugins/event_annotation/common';
const onClickValue = jest.fn();
const onSelectRange = jest.fn();
@@ -536,6 +539,7 @@ describe('xy_expression', () => {
onSelectRange,
syncColors: false,
useLegacyTimeAxis: false,
+ eventAnnotationService: eventAnnotationServiceMock,
};
});
@@ -546,7 +550,7 @@ describe('xy_expression', () => {
);
expect(component).toMatchSnapshot();
@@ -613,7 +617,9 @@ describe('xy_expression', () => {
}}
args={{
...args,
- layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'time' }],
+ layers: [
+ { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time' },
+ ],
}}
minInterval={undefined}
/>
@@ -802,7 +808,7 @@ describe('xy_expression', () => {
...args,
layers: [
{
- ...args.layers[0],
+ ...(args.layers[0] as DataLayerArgs),
seriesType: 'line',
xScaleType: 'time',
isHistogram: true,
@@ -878,7 +884,7 @@ describe('xy_expression', () => {
...args,
layers: [
{
- ...args.layers[0],
+ ...(args.layers[0] as DataLayerArgs),
seriesType: 'bar',
xScaleType: 'time',
isHistogram: true,
@@ -975,7 +981,7 @@ describe('xy_expression', () => {
},
layers: [
{
- ...args.layers[0],
+ ...(args.layers[0] as DataLayerArgs),
seriesType: 'area',
},
],
@@ -1006,7 +1012,7 @@ describe('xy_expression', () => {
},
layers: [
{
- ...args.layers[0],
+ ...(args.layers[0] as DataLayerArgs),
seriesType: 'bar',
},
],
@@ -1083,7 +1089,9 @@ describe('xy_expression', () => {
}}
args={{
...args,
- layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'linear' }],
+ layers: [
+ { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'linear' },
+ ],
}}
/>
);
@@ -1102,7 +1110,12 @@ describe('xy_expression', () => {
args={{
...args,
layers: [
- { ...args.layers[0], seriesType: 'line', xScaleType: 'linear', isHistogram: true },
+ {
+ ...(args.layers[0] as DataLayerArgs),
+ seriesType: 'line',
+ xScaleType: 'linear',
+ isHistogram: true,
+ },
],
}}
/>
@@ -1150,7 +1163,7 @@ describe('xy_expression', () => {
);
expect(component).toMatchSnapshot();
@@ -1165,7 +1178,7 @@ describe('xy_expression', () => {
);
expect(component).toMatchSnapshot();
@@ -1180,7 +1193,10 @@ describe('xy_expression', () => {
);
expect(component).toMatchSnapshot();
@@ -1678,7 +1694,10 @@ describe('xy_expression', () => {
);
expect(component).toMatchSnapshot();
@@ -1693,7 +1712,10 @@ describe('xy_expression', () => {
);
expect(component).toMatchSnapshot();
@@ -1710,7 +1732,9 @@ describe('xy_expression', () => {
data={data}
args={{
...args,
- layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }],
+ layers: [
+ { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_horizontal_stacked' },
+ ],
}}
/>
);
@@ -1732,7 +1756,7 @@ describe('xy_expression', () => {
...args,
layers: [
{
- ...args.layers[0],
+ ...(args.layers[0] as DataLayerArgs),
xAccessor: undefined,
splitAccessor: 'e',
seriesType: 'bar_stacked',
@@ -1762,7 +1786,7 @@ describe('xy_expression', () => {
accessors: ['b'],
seriesType: 'bar',
isHistogram: true,
- };
+ } as DataLayerArgs;
delete firstLayer.splitAccessor;
const component = shallow(
@@ -1772,7 +1796,11 @@ describe('xy_expression', () => {
test('it does not apply histogram mode to more than one bar series for unstacked bar chart', () => {
const { data, args } = sampleArgs();
- const firstLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true };
+ const firstLayer: DataLayerArgs = {
+ ...args.layers[0],
+ seriesType: 'bar',
+ isHistogram: true,
+ } as DataLayerArgs;
delete firstLayer.splitAccessor;
const component = shallow(
@@ -1787,13 +1815,13 @@ describe('xy_expression', () => {
...args.layers[0],
seriesType: 'line',
isHistogram: true,
- };
+ } as DataLayerArgs;
delete firstLayer.splitAccessor;
const secondLayer: DataLayerArgs = {
...args.layers[0],
seriesType: 'line',
isHistogram: true,
- };
+ } as DataLayerArgs;
delete secondLayer.splitAccessor;
const component = shallow(
{
...args,
layers: [
{
- ...args.layers[0],
+ ...(args.layers[0] as DataLayerArgs),
seriesType: 'bar_stacked',
isHistogram: true,
},
@@ -1836,7 +1864,9 @@ describe('xy_expression', () => {
data={data}
args={{
...args,
- layers: [{ ...args.layers[0], seriesType: 'bar', isHistogram: true }],
+ layers: [
+ { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', isHistogram: true },
+ ],
}}
/>
);
@@ -2232,7 +2262,10 @@ describe('xy_expression', () => {
);
expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal);
@@ -2246,7 +2279,7 @@ describe('xy_expression', () => {
);
expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt);
@@ -2268,7 +2301,7 @@ describe('xy_expression', () => {
);
expect(getFormatSpy).toHaveBeenCalledWith({
@@ -2678,7 +2711,9 @@ describe('xy_expression', () => {
data={{ ...data }}
args={{
...args,
- layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }],
+ layers: [
+ { ...(args.layers[0] as DataLayerArgs), accessors: ['a'], splitAccessor: undefined },
+ ],
legend: { ...args.legend, isVisible: true, showSingleSeries: true },
}}
/>
@@ -2696,7 +2731,13 @@ describe('xy_expression', () => {
data={{ ...data }}
args={{
...args,
- layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }],
+ layers: [
+ {
+ ...(args.layers[0] as DataLayerArgs),
+ accessors: ['a'],
+ splitAccessor: undefined,
+ },
+ ],
legend: { ...args.legend, isVisible: true, isInside: true },
}}
/>
@@ -2782,7 +2823,7 @@ describe('xy_expression', () => {
test('it should apply None fitting function if not specified', () => {
const { data, args } = sampleArgs();
- args.layers[0].accessors = ['a'];
+ (args.layers[0] as DataLayerArgs).accessors = ['a'];
const component = shallow(
@@ -2920,6 +2961,139 @@ describe('xy_expression', () => {
},
]);
});
+
+ describe('annotations', () => {
+ const sampleStyledAnnotation: EventAnnotationOutput = {
+ time: '2022-03-18T08:25:00.000Z',
+ label: 'Event 1',
+ icon: 'triangle',
+ type: 'manual_event_annotation',
+ color: 'red',
+ lineStyle: 'dashed',
+ lineWidth: 3,
+ };
+ const sampleAnnotationLayers: AnnotationLayerArgs[] = [
+ {
+ layerType: layerTypes.ANNOTATIONS,
+ layerId: 'annotation',
+ annotations: [
+ {
+ time: '2022-03-18T08:25:17.140Z',
+ label: 'Annotation',
+ type: 'manual_event_annotation',
+ },
+ ],
+ },
+ ];
+ function sampleArgsWithAnnotation(annotationLayers = sampleAnnotationLayers) {
+ const { args } = sampleArgs();
+ return {
+ data: dateHistogramData,
+ args: {
+ ...args,
+ layers: [dateHistogramLayer, ...annotationLayers],
+ } as XYArgs,
+ };
+ }
+ test('should render basic annotation', () => {
+ const { data, args } = sampleArgsWithAnnotation();
+ const component = mount();
+ expect(component.find('LineAnnotation')).toMatchSnapshot();
+ });
+ test('should render simplified annotation when hide is true', () => {
+ const { data, args } = sampleArgsWithAnnotation();
+ args.layers[0].hide = true;
+ const component = mount();
+ expect(component.find('LineAnnotation')).toMatchSnapshot();
+ });
+
+ test('should render grouped annotations preserving the shared styles', () => {
+ const { data, args } = sampleArgsWithAnnotation([
+ {
+ layerType: layerTypes.ANNOTATIONS,
+ layerId: 'annotation',
+ annotations: [
+ sampleStyledAnnotation,
+ { ...sampleStyledAnnotation, time: '2022-03-18T08:25:00.020Z', label: 'Event 2' },
+ {
+ ...sampleStyledAnnotation,
+ time: '2022-03-18T08:25:00.001Z',
+ label: 'Event 3',
+ },
+ ],
+ },
+ ]);
+ const component = mount();
+ const groupedAnnotation = component.find(LineAnnotation);
+
+ expect(groupedAnnotation.length).toEqual(1);
+ // styles are passed because they are shared, dataValues & header is rounded to the interval
+ expect(groupedAnnotation).toMatchSnapshot();
+ // renders numeric icon for grouped annotations
+ const marker = mount({groupedAnnotation.prop('marker')}
);
+ const numberIcon = marker.find('NumberIcon');
+ expect(numberIcon.length).toEqual(1);
+ expect(numberIcon.text()).toEqual('3');
+
+ // checking tooltip
+ const renderLinks = mount({groupedAnnotation.prop('customTooltipDetails')!()}
);
+ expect(renderLinks.text()).toEqual(
+ ' Event 1 2022-03-18T08:25:00.000Z Event 2 2022-03-18T08:25:00.020Z Event 3 2022-03-18T08:25:00.001Z'
+ );
+ });
+ test('should render grouped annotations with default styles', () => {
+ const { data, args } = sampleArgsWithAnnotation([
+ {
+ layerType: layerTypes.ANNOTATIONS,
+ layerId: 'annotation',
+ annotations: [sampleStyledAnnotation],
+ },
+ {
+ layerType: layerTypes.ANNOTATIONS,
+ layerId: 'annotation',
+ annotations: [
+ {
+ ...sampleStyledAnnotation,
+ icon: 'square',
+ color: 'blue',
+ lineStyle: 'dotted',
+ lineWidth: 10,
+ time: '2022-03-18T08:25:00.001Z',
+ label: 'Event 2',
+ },
+ ],
+ },
+ ]);
+ const component = mount();
+ const groupedAnnotation = component.find(LineAnnotation);
+
+ expect(groupedAnnotation.length).toEqual(1);
+ // styles are default because they are different for both annotations
+ expect(groupedAnnotation).toMatchSnapshot();
+ });
+ test('should not render hidden annotations', () => {
+ const { data, args } = sampleArgsWithAnnotation([
+ {
+ layerType: layerTypes.ANNOTATIONS,
+ layerId: 'annotation',
+ annotations: [
+ sampleStyledAnnotation,
+ { ...sampleStyledAnnotation, time: '2022-03-18T08:30:00.020Z', label: 'Event 2' },
+ {
+ ...sampleStyledAnnotation,
+ time: '2022-03-18T08:35:00.001Z',
+ label: 'Event 3',
+ isHidden: true,
+ },
+ ],
+ },
+ ]);
+ const component = mount();
+ const annotations = component.find(LineAnnotation);
+
+ expect(annotations.length).toEqual(2);
+ });
+ });
});
describe('calculateMinInterval', () => {
@@ -2927,7 +3101,7 @@ describe('xy_expression', () => {
beforeEach(() => {
xyProps = sampleArgs();
- xyProps.args.layers[0].xScaleType = 'time';
+ (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'time';
});
it('should use first valid layer and determine interval', async () => {
xyProps.data.tables.first.columns[2].meta.source = 'esaggs';
@@ -2942,7 +3116,7 @@ describe('xy_expression', () => {
});
it('should return interval of number histogram if available on first x axis columns', async () => {
- xyProps.args.layers[0].xScaleType = 'linear';
+ (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'linear';
xyProps.data.tables.first.columns[2].meta = {
source: 'esaggs',
type: 'number',
@@ -2984,7 +3158,7 @@ describe('xy_expression', () => {
});
it('should return undefined if x axis is not a date', async () => {
- xyProps.args.layers[0].xScaleType = 'ordinal';
+ (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'ordinal';
xyProps.data.tables.first.columns.splice(2, 1);
const result = await calculateMinInterval(xyProps);
expect(result).toEqual(undefined);
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
index 72a3f5f4f6976..105b9d24bb09b 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx
@@ -50,11 +50,17 @@ import { i18n } from '@kbn/i18n';
import { RenderMode } from 'src/plugins/expressions';
import { ThemeServiceStart } from 'kibana/public';
import { FieldFormat } from 'src/plugins/field_formats/common';
+import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public';
import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public';
import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types';
import type { LensMultiTable, FormatFactory } from '../../common';
-import type { DataLayerArgs, SeriesType, XYChartProps } from '../../common/expressions';
+import type {
+ DataLayerArgs,
+ SeriesType,
+ XYChartProps,
+ XYLayerArgs,
+} from '../../common/expressions';
import { visualizationTypes } from './types';
import { VisualizationContainer } from '../visualization_container';
import { isHorizontalChart, getSeriesColor } from './state_helpers';
@@ -72,13 +78,17 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe
import { getColorAssignments } from './color_assignment';
import { getXDomain, XyEndzones } from './x_domain';
import { getLegendAction } from './get_legend_action';
-import {
- computeChartMargins,
- getReferenceLineRequiredPaddings,
- ReferenceLineAnnotations,
-} from './expression_reference_lines';
+import { ReferenceLineAnnotations } from './expression_reference_lines';
+
+import { computeChartMargins, getLinesCausedPaddings } from './annotations_helpers';
+
+import { Annotations, getAnnotationsGroupedByInterval } from './annotations/expression';
import { computeOverallDataDomain } from './reference_line_helpers';
-import { getReferenceLayers, isDataLayer } from './visualization_helpers';
+import {
+ getReferenceLayers,
+ getDataLayersArgs,
+ getAnnotationsLayersArgs,
+} from './visualization_helpers';
declare global {
interface Window {
@@ -104,6 +114,7 @@ export type XYChartRenderProps = XYChartProps & {
onSelectRange: (data: LensBrushEvent['data']) => void;
renderMode: RenderMode;
syncColors: boolean;
+ eventAnnotationService: EventAnnotationServiceType;
};
export function calculateMinInterval({ args: { layers }, data }: XYChartProps) {
@@ -140,6 +151,7 @@ export const getXyChartRenderer = (dependencies: {
timeZone: string;
useLegacyTimeAxis: boolean;
kibanaTheme: ThemeServiceStart;
+ eventAnnotationService: EventAnnotationServiceType;
}): ExpressionRenderDefinition => ({
name: 'lens_xy_chart_renderer',
displayName: 'XY chart',
@@ -170,6 +182,7 @@ export const getXyChartRenderer = (dependencies: {
chartsActiveCursorService={dependencies.chartsActiveCursorService}
chartsThemeService={dependencies.chartsThemeService}
paletteService={dependencies.paletteService}
+ eventAnnotationService={dependencies.eventAnnotationService}
timeZone={dependencies.timeZone}
useLegacyTimeAxis={dependencies.useLegacyTimeAxis}
minInterval={calculateMinInterval(config)}
@@ -265,7 +278,9 @@ export function XYChart({
});
if (filteredLayers.length === 0) {
- const icon: IconType = getIconForSeriesType(layers?.[0]?.seriesType || 'bar');
+ const icon: IconType = getIconForSeriesType(
+ getDataLayersArgs(layers)?.[0]?.seriesType || 'bar'
+ );
return ;
}
@@ -279,8 +294,8 @@ export function XYChart({
// This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers
const safeXAccessorLabelRenderer = (value: unknown): string =>
xAxisColumn && layersAlreadyFormatted[xAxisColumn.id]
- ? (value as string)
- : xAxisFormatter.convert(value);
+ ? String(value)
+ : String(xAxisFormatter.convert(value));
const chartHasMoreThanOneSeries =
filteredLayers.length > 1 ||
@@ -353,7 +368,23 @@ export function XYChart({
};
const referenceLineLayers = getReferenceLayers(layers);
- const referenceLinePaddings = getReferenceLineRequiredPaddings(referenceLineLayers, yAxesMap);
+ const annotationsLayers = getAnnotationsLayersArgs(layers);
+ const firstTable = data.tables[filteredLayers[0].layerId];
+
+ const xColumnId = firstTable.columns.find((col) => col.id === filteredLayers[0].xAccessor)?.id;
+
+ const groupedAnnotations = getAnnotationsGroupedByInterval(
+ annotationsLayers,
+ minInterval,
+ xColumnId ? firstTable.rows[0]?.[xColumnId] : undefined,
+ xAxisFormatter
+ );
+ const visualConfigs = [
+ ...referenceLineLayers.flatMap(({ yConfig }) => yConfig),
+ ...groupedAnnotations,
+ ].filter(Boolean);
+
+ const linesPaddings = getLinesCausedPaddings(visualConfigs, yAxesMap);
const getYAxesStyle = (groupId: 'left' | 'right') => {
const tickVisible =
@@ -369,9 +400,9 @@ export function XYChart({
? args.labelsOrientation?.yRight || 0
: args.labelsOrientation?.yLeft || 0,
padding:
- referenceLinePaddings[groupId] != null
+ linesPaddings[groupId] != null
? {
- inner: referenceLinePaddings[groupId],
+ inner: linesPaddings[groupId],
}
: undefined,
},
@@ -382,9 +413,9 @@ export function XYChart({
: axisTitlesVisibilitySettings?.yLeft,
// if labels are not visible add the padding to the title
padding:
- !tickVisible && referenceLinePaddings[groupId] != null
+ !tickVisible && linesPaddings[groupId] != null
? {
- inner: referenceLinePaddings[groupId],
+ inner: linesPaddings[groupId],
}
: undefined,
},
@@ -458,7 +489,7 @@ export function XYChart({
const valueLabelsStyling =
shouldShowValueLabels && valueLabels !== 'hide' && getValueLabelsStyling(shouldRotate);
- const colorAssignments = getColorAssignments(args.layers, data, formatFactory);
+ const colorAssignments = getColorAssignments(getDataLayersArgs(args.layers), data, formatFactory);
const clickHandler: ElementClickListener = ([[geometry, series]]) => {
// for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue
@@ -591,16 +622,13 @@ export function XYChart({
tickLabel: {
visible: tickLabelsVisibilitySettings?.x,
rotation: labelsOrientation?.x,
- padding:
- referenceLinePaddings.bottom != null
- ? { inner: referenceLinePaddings.bottom }
- : undefined,
+ padding: linesPaddings.bottom != null ? { inner: linesPaddings.bottom } : undefined,
},
axisTitle: {
visible: axisTitlesVisibilitySettings.x,
padding:
- !tickLabelsVisibilitySettings?.x && referenceLinePaddings.bottom != null
- ? { inner: referenceLinePaddings.bottom }
+ !tickLabelsVisibilitySettings?.x && linesPaddings.bottom != null
+ ? { inner: linesPaddings.bottom }
: undefined,
},
};
@@ -633,7 +661,7 @@ export function XYChart({
chartMargins: {
...chartTheme.chartPaddings,
...computeChartMargins(
- referenceLinePaddings,
+ linesPaddings,
tickLabelsVisibilitySettings,
axisTitlesVisibilitySettings,
yAxesMap,
@@ -1005,29 +1033,37 @@ export function XYChart({
right: Boolean(yAxesMap.right),
}}
isHorizontal={shouldRotate}
- paddingMap={referenceLinePaddings}
+ paddingMap={linesPaddings}
+ />
+ ) : null}
+ {groupedAnnotations.length ? (
+ 0}
+ minInterval={minInterval}
/>
) : null}
);
}
-function getFilteredLayers(layers: DataLayerArgs[], data: LensMultiTable) {
- return layers.filter((layer) => {
+function getFilteredLayers(layers: XYLayerArgs[], data: LensMultiTable) {
+ return getDataLayersArgs(layers).filter((layer) => {
const { layerId, xAccessor, accessors, splitAccessor } = layer;
- return (
- isDataLayer(layer) &&
- !(
- !accessors.length ||
- !data.tables[layerId] ||
- data.tables[layerId].rows.length === 0 ||
- (xAccessor &&
- data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) ||
- // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty
- (!xAccessor &&
- splitAccessor &&
- data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined'))
- )
+ return !(
+ !accessors.length ||
+ !data.tables[layerId] ||
+ data.tables[layerId].rows.length === 0 ||
+ (xAccessor &&
+ data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) ||
+ // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty
+ (!xAccessor &&
+ splitAccessor &&
+ data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined'))
);
});
}
diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx
index 2d22f6a6ed76e..7817db573e419 100644
--- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx
@@ -8,183 +8,19 @@
import './expression_reference_lines.scss';
import React from 'react';
import { groupBy } from 'lodash';
-import { EuiIcon } from '@elastic/eui';
import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts';
import type { PaletteRegistry } from 'src/plugins/charts/public';
import type { FieldFormat } from 'src/plugins/field_formats/common';
-import { euiLightVars } from '@kbn/ui-theme';
-import type { IconPosition, ReferenceLineLayerArgs, YAxisMode } from '../../common/expressions';
+import type { ReferenceLineLayerArgs } from '../../common/expressions';
import type { LensMultiTable } from '../../common/types';
-import { hasIcon } from './xy_config_panel/shared/icon_select';
-
-export const REFERENCE_LINE_MARKER_SIZE = 20;
-
-export const computeChartMargins = (
- referenceLinePaddings: Partial>,
- labelVisibility: Partial>,
- titleVisibility: Partial>,
- axesMap: Record<'left' | 'right', unknown>,
- isHorizontal: boolean
-) => {
- const result: Partial> = {};
- if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) {
- const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom';
- result[placement] = referenceLinePaddings.bottom;
- }
- if (
- referenceLinePaddings.left &&
- (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft))
- ) {
- const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left';
- result[placement] = referenceLinePaddings.left;
- }
- if (
- referenceLinePaddings.right &&
- (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight))
- ) {
- const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right';
- result[placement] = referenceLinePaddings.right;
- }
- // there's no top axis, so just check if a margin has been computed
- if (referenceLinePaddings.top) {
- const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top';
- result[placement] = referenceLinePaddings.top;
- }
- return result;
-};
-
-// Note: it does not take into consideration whether the reference line is in view or not
-export const getReferenceLineRequiredPaddings = (
- referenceLineLayers: ReferenceLineLayerArgs[],
- axesMap: Record<'left' | 'right', unknown>
-) => {
- // collect all paddings for the 4 axis: if any text is detected double it.
- const paddings: Partial> = {};
- const icons: Partial> = {};
- referenceLineLayers.forEach((layer) => {
- layer.yConfig?.forEach(({ axisMode, icon, iconPosition, textVisibility }) => {
- if (axisMode && (hasIcon(icon) || textVisibility)) {
- const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap);
- paddings[placement] = Math.max(
- paddings[placement] || 0,
- REFERENCE_LINE_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text
- );
- icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0);
- }
- });
- });
- // post-process the padding based on the icon presence:
- // if no icon is present for the placement, just reduce the padding
- (Object.keys(paddings) as Position[]).forEach((placement) => {
- if (!icons[placement]) {
- paddings[placement] = REFERENCE_LINE_MARKER_SIZE;
- }
- });
-
- return paddings;
-};
-
-function mapVerticalToHorizontalPlacement(placement: Position) {
- switch (placement) {
- case Position.Top:
- return Position.Right;
- case Position.Bottom:
- return Position.Left;
- case Position.Left:
- return Position.Bottom;
- case Position.Right:
- return Position.Top;
- }
-}
-
-// if there's just one axis, put it on the other one
-// otherwise use the same axis
-// this function assume the chart is vertical
-function getBaseIconPlacement(
- iconPosition: IconPosition | undefined,
- axisMode: YAxisMode | undefined,
- axesMap: Record
-) {
- if (iconPosition === 'auto') {
- if (axisMode === 'bottom') {
- return Position.Top;
- }
- if (axisMode === 'left') {
- return axesMap.right ? Position.Left : Position.Right;
- }
- return axesMap.left ? Position.Right : Position.Left;
- }
-
- if (iconPosition === 'left') {
- return Position.Left;
- }
- if (iconPosition === 'right') {
- return Position.Right;
- }
- if (iconPosition === 'below') {
- return Position.Bottom;
- }
- return Position.Top;
-}
-
-function getMarkerBody(label: string | undefined, isHorizontal: boolean) {
- if (!label) {
- return;
- }
- if (isHorizontal) {
- return (
-
- {label}
-
- );
- }
- return (
-
- );
-}
-
-interface MarkerConfig {
- axisMode?: YAxisMode;
- icon?: string;
- textVisibility?: boolean;
-}
-
-function getMarkerToShow(
- markerConfig: MarkerConfig,
- label: string | undefined,
- isHorizontal: boolean,
- hasReducedPadding: boolean
-) {
- // show an icon if present
- if (hasIcon(markerConfig.icon)) {
- return ;
- }
- // if there's some text, check whether to show it as marker, or just show some padding for the icon
- if (markerConfig.textVisibility) {
- if (hasReducedPadding) {
- return getMarkerBody(
- label,
- (!isHorizontal && markerConfig.axisMode === 'bottom') ||
- (isHorizontal && markerConfig.axisMode !== 'bottom')
- );
- }
- return ;
- }
-}
+import { defaultReferenceLineColor } from './color_assignment';
+import {
+ MarkerBody,
+ Marker,
+ LINES_MARKER_SIZE,
+ mapVerticalToHorizontalPlacement,
+ getBaseIconPlacement,
+} from './annotations_helpers';
export interface ReferenceLineAnnotationsProps {
layers: ReferenceLineLayerArgs[];
@@ -241,32 +77,40 @@ export const ReferenceLineAnnotations = ({
const formatter = formatters[groupId || 'bottom'];
- const defaultColor = euiLightVars.euiColorDarkShade;
-
// get the position for vertical chart
const markerPositionVertical = getBaseIconPlacement(
yConfig.iconPosition,
- yConfig.axisMode,
- axesMap
+ axesMap,
+ yConfig.axisMode
);
// the padding map is built for vertical chart
- const hasReducedPadding =
- paddingMap[markerPositionVertical] === REFERENCE_LINE_MARKER_SIZE;
+ const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE;
const props = {
groupId,
- marker: getMarkerToShow(
- yConfig,
- columnToLabelMap[yConfig.forAccessor],
- isHorizontal,
- hasReducedPadding
+ marker: (
+
),
- markerBody: getMarkerBody(
- yConfig.textVisibility && !hasReducedPadding
- ? columnToLabelMap[yConfig.forAccessor]
- : undefined,
- (!isHorizontal && yConfig.axisMode === 'bottom') ||
- (isHorizontal && yConfig.axisMode !== 'bottom')
+ markerBody: (
+
),
// rotate the position if required
markerPosition: isHorizontal
@@ -284,7 +128,7 @@ export const ReferenceLineAnnotations = ({
const sharedStyle = {
strokeWidth: yConfig.lineWidth || 1,
- stroke: yConfig.color || defaultColor,
+ stroke: yConfig.color || defaultReferenceLineColor,
dash: dashStyle,
};
@@ -355,7 +199,7 @@ export const ReferenceLineAnnotations = ({
})}
style={{
...sharedStyle,
- fill: yConfig.color || defaultColor,
+ fill: yConfig.color || defaultReferenceLineColor,
opacity: 0.1,
}}
/>
diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts
index 9697ba149e16e..cfeb1387f689c 100644
--- a/x-pack/plugins/lens/public/xy_visualization/index.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/index.ts
@@ -6,6 +6,7 @@
*/
import type { CoreSetup } from 'kibana/public';
+import { EventAnnotationPluginSetup } from '../../../../../src/plugins/event_annotation/public';
import type { ExpressionsSetup } from '../../../../../src/plugins/expressions/public';
import type { EditorFrameSetup } from '../types';
import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
@@ -19,6 +20,7 @@ export interface XyVisualizationPluginSetupPlugins {
formatFactory: FormatFactory;
editorFrame: EditorFrameSetup;
charts: ChartsPluginSetup;
+ eventAnnotation: EventAnnotationPluginSetup;
}
export class XyVisualization {
@@ -28,8 +30,9 @@ export class XyVisualization {
) {
editorFrame.registerVisualization(async () => {
const { getXyChartRenderer, getXyVisualization } = await import('../async_services');
- const [, { charts, fieldFormats }] = await core.getStartServices();
+ const [, { charts, fieldFormats, eventAnnotation }] = await core.getStartServices();
const palettes = await charts.palettes.getPalettes();
+ const eventAnnotationService = await eventAnnotation.getService();
const useLegacyTimeAxis = core.uiSettings.get(LEGACY_TIME_AXIS);
expressions.registerRenderer(
getXyChartRenderer({
@@ -37,6 +40,7 @@ export class XyVisualization {
chartsThemeService: charts.theme,
chartsActiveCursorService: charts.activeCursor,
paletteService: palettes,
+ eventAnnotationService,
timeZone: getTimeZone(core.uiSettings),
useLegacyTimeAxis,
kibanaTheme: core.theme,
@@ -44,6 +48,7 @@ export class XyVisualization {
);
return getXyVisualization({
paletteService: palettes,
+ eventAnnotationService,
fieldFormats,
useLegacyTimeAxis,
kibanaTheme: core.theme,
diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx
index ac50a81da5423..8b6a96ce24d44 100644
--- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx
@@ -14,7 +14,7 @@ import type {
YConfig,
} from '../../common/expressions';
import { Datatable } from '../../../../../src/plugins/expressions/public';
-import type { AccessorConfig, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types';
+import type { DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types';
import { groupAxesByType } from './axes_configuration';
import { isHorizontalChart, isPercentageSeries, isStackedChart } from './state_helpers';
import type { XYState } from './types';
@@ -27,6 +27,7 @@ import {
} from './visualization_helpers';
import { generateId } from '../id_generator';
import { LensIconChartBarReferenceLine } from '../assets/chart_bar_reference_line';
+import { defaultReferenceLineColor } from './color_assignment';
export interface ReferenceLineBase {
label: 'x' | 'yRight' | 'yLeft';
@@ -360,18 +361,29 @@ export const setReferenceDimension: Visualization['setDimension'] = ({
};
};
+const getSingleColorConfig = (id: string, color = defaultReferenceLineColor) => ({
+ columnId: id,
+ triggerIcon: 'color' as const,
+ color,
+});
+
+export const getReferenceLineAccessorColorConfig = (layer: XYReferenceLineLayerConfig) => {
+ return layer.accessors.map((accessor) => {
+ const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor);
+ return getSingleColorConfig(accessor, currentYConfig?.color);
+ });
+};
+
export const getReferenceConfiguration = ({
state,
frame,
layer,
sortedAccessors,
- mappedAccessors,
}: {
state: XYState;
frame: FramePublicAPI;
layer: XYReferenceLineLayerConfig;
sortedAccessors: string[];
- mappedAccessors: AccessorConfig[];
}) => {
const idToIndex = sortedAccessors.reduce>((memo, id, index) => {
memo[id] = index;
@@ -420,11 +432,7 @@ export const getReferenceConfiguration = ({
groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({
groupId: id,
groupLabel: getAxisName(label, { isHorizontal }),
- accessors: config.map(({ forAccessor, color }) => ({
- columnId: forAccessor,
- color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color,
- triggerIcon: 'color' as const,
- })),
+ accessors: config.map(({ forAccessor, color }) => getSingleColorConfig(forAccessor, color)),
filterOperations: isNumericMetric,
supportsMoreColumns: true,
required: false,
diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts
index dee7899740173..e0984e62cb9cc 100644
--- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts
@@ -16,7 +16,7 @@ import type {
XYReferenceLineLayerConfig,
} from '../../common/expressions';
import { visualizationTypes } from './types';
-import { getDataLayers, isDataLayer } from './visualization_helpers';
+import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers';
export function isHorizontalSeries(seriesType: SeriesType) {
return (
@@ -53,6 +53,9 @@ export function getIconForSeries(type: SeriesType): EuiIconType {
}
export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => {
+ if (isAnnotationsLayer(layer)) {
+ return layer?.annotations?.find((ann) => ann.id === accessor)?.color || null;
+ }
if (isDataLayer(layer) && layer.splitAccessor) {
return null;
}
diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts
index fa992d8829b20..2e3db8f2f6f93 100644
--- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts
@@ -15,6 +15,7 @@ import { layerTypes } from '../../common';
import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks';
import { defaultReferenceLineColor } from './color_assignment';
import { themeServiceMock } from '../../../../../src/core/public/mocks';
+import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks';
describe('#toExpression', () => {
const xyVisualization = getXyVisualization({
@@ -22,6 +23,7 @@ describe('#toExpression', () => {
fieldFormats: fieldFormatsServiceMock.createStartContract(),
kibanaTheme: themeServiceMock.createStartContract(),
useLegacyTimeAxis: false,
+ eventAnnotationService: eventAnnotationServiceMock,
});
let mockDatasource: ReturnType;
let frame: ReturnType;
diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts
index a9c166a9c13eb..ade90ff98e553 100644
--- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts
@@ -8,29 +8,40 @@
import { Ast } from '@kbn/interpreter';
import { ScaleType } from '@elastic/charts';
import { PaletteRegistry } from 'src/plugins/charts/public';
+import { EventAnnotationServiceType } from 'src/plugins/event_annotation/public';
import { State } from './types';
import { OperationMetadata, DatasourcePublicAPI } from '../types';
import { getColumnToLabelMap } from './state_helpers';
import type {
ValidLayer,
- XYDataLayerConfig,
+ XYAnnotationLayerConfig,
XYReferenceLineLayerConfig,
YConfig,
+ XYDataLayerConfig,
} from '../../common/expressions';
import { layerTypes } from '../../common';
import { hasIcon } from './xy_config_panel/shared/icon_select';
import { defaultReferenceLineColor } from './color_assignment';
import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values';
-import { isDataLayer } from './visualization_helpers';
+import {
+ getLayerTypeOptions,
+ getDataLayers,
+ getReferenceLayers,
+ getAnnotationsLayers,
+} from './visualization_helpers';
+import { defaultAnnotationLabel } from './annotations/config_panel';
+import { getUniqueLabels } from './annotations/helpers';
export const getSortedAccessors = (
datasource: DatasourcePublicAPI,
layer: XYDataLayerConfig | XYReferenceLineLayerConfig
) => {
const originalOrder = datasource
- .getTableSpec()
- .map(({ columnId }: { columnId: string }) => columnId)
- .filter((columnId: string) => layer.accessors.includes(columnId));
+ ? datasource
+ .getTableSpec()
+ .map(({ columnId }: { columnId: string }) => columnId)
+ .filter((columnId: string) => layer.accessors.includes(columnId))
+ : layer.accessors;
// When we add a column it could be empty, and therefore have no order
return Array.from(new Set(originalOrder.concat(layer.accessors)));
};
@@ -39,7 +50,8 @@ export const toExpression = (
state: State,
datasourceLayers: Record,
paletteService: PaletteRegistry,
- attributes: Partial<{ title: string; description: string }> = {}
+ attributes: Partial<{ title: string; description: string }> = {},
+ eventAnnotationService: EventAnnotationServiceType
): Ast | null => {
if (!state || !state.layers.length) {
return null;
@@ -49,38 +61,58 @@ export const toExpression = (
state.layers.forEach((layer) => {
metadata[layer.layerId] = {};
const datasource = datasourceLayers[layer.layerId];
- datasource.getTableSpec().forEach((column) => {
- const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId);
- metadata[layer.layerId][column.columnId] = operation;
- });
+ if (datasource) {
+ datasource.getTableSpec().forEach((column) => {
+ const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId);
+ metadata[layer.layerId][column.columnId] = operation;
+ });
+ }
});
- return buildExpression(state, metadata, datasourceLayers, paletteService, attributes);
+ return buildExpression(
+ state,
+ metadata,
+ datasourceLayers,
+ paletteService,
+ attributes,
+ eventAnnotationService
+ );
+};
+
+const simplifiedLayerExpression = {
+ [layerTypes.DATA]: (layer: XYDataLayerConfig) => ({ ...layer, hide: true }),
+ [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => ({
+ ...layer,
+ hide: true,
+ yConfig: layer.yConfig?.map(({ lineWidth, ...rest }) => ({
+ ...rest,
+ lineWidth: 1,
+ icon: undefined,
+ textVisibility: false,
+ })),
+ }),
+ [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => ({
+ ...layer,
+ hide: true,
+ annotations: layer.annotations?.map(({ lineWidth, ...rest }) => ({
+ ...rest,
+ lineWidth: 1,
+ icon: undefined,
+ textVisibility: false,
+ })),
+ }),
};
export function toPreviewExpression(
state: State,
datasourceLayers: Record,
- paletteService: PaletteRegistry
+ paletteService: PaletteRegistry,
+ eventAnnotationService: EventAnnotationServiceType
) {
return toExpression(
{
...state,
- layers: state.layers.map((layer) =>
- isDataLayer(layer)
- ? { ...layer, hide: true }
- : // cap the reference line to 1px
- {
- ...layer,
- hide: true,
- yConfig: layer.yConfig?.map(({ lineWidth, ...config }) => ({
- ...config,
- lineWidth: 1,
- icon: undefined,
- textVisibility: false,
- })),
- }
- ),
+ layers: state.layers.map((layer) => getLayerTypeOptions(layer, simplifiedLayerExpression)),
// hide legend for preview
legend: {
...state.legend,
@@ -90,7 +122,8 @@ export function toPreviewExpression(
},
datasourceLayers,
paletteService,
- {}
+ {},
+ eventAnnotationService
);
}
@@ -125,23 +158,35 @@ export const buildExpression = (
metadata: Record>,
datasourceLayers: Record,
paletteService: PaletteRegistry,
- attributes: Partial<{ title: string; description: string }> = {}
+ attributes: Partial<{ title: string; description: string }> = {},
+ eventAnnotationService: EventAnnotationServiceType
): Ast | null => {
- const validLayers = state.layers
+ const validDataLayers = getDataLayers(state.layers)
.filter((layer): layer is ValidLayer => Boolean(layer.accessors.length))
- .map((layer) => {
- if (!datasourceLayers) {
- return layer;
- }
- const sortedAccessors = getSortedAccessors(datasourceLayers[layer.layerId], layer);
+ .map((layer) => ({
+ ...layer,
+ accessors: getSortedAccessors(datasourceLayers[layer.layerId], layer),
+ }));
+
+ // sorting doesn't change anything so we don't sort reference layers (TODO: should we make it work?)
+ const validReferenceLayers = getReferenceLayers(state.layers).filter((layer) =>
+ Boolean(layer.accessors.length)
+ );
+ const uniqueLabels = getUniqueLabels(state.layers);
+ const validAnnotationsLayers = getAnnotationsLayers(state.layers)
+ .filter((layer) => Boolean(layer.annotations.length))
+ .map((layer) => {
return {
...layer,
- accessors: sortedAccessors,
+ annotations: layer.annotations.map((c) => ({
+ ...c,
+ label: uniqueLabels[c.id],
+ })),
};
});
- if (!validLayers.length) {
+ if (!validDataLayers.length) {
return null;
}
@@ -309,20 +354,25 @@ export const buildExpression = (
valueLabels: [state?.valueLabels || 'hide'],
hideEndzones: [state?.hideEndzones || false],
valuesInLegend: [state?.valuesInLegend || false],
- layers: validLayers.map((layer) => {
- if (isDataLayer(layer)) {
- return dataLayerToExpression(
+ layers: [
+ ...validDataLayers.map((layer) =>
+ dataLayerToExpression(
layer,
datasourceLayers[layer.layerId],
metadata,
paletteService
- );
- }
- return referenceLineLayerToExpression(
- layer,
- datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId]
- );
- }),
+ )
+ ),
+ ...validReferenceLayers.map((layer) =>
+ referenceLineLayerToExpression(
+ layer,
+ datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId]
+ )
+ ),
+ ...validAnnotationsLayers.map((layer) =>
+ annotationLayerToExpression(layer, eventAnnotationService)
+ ),
+ ],
},
},
],
@@ -355,6 +405,41 @@ const referenceLineLayerToExpression = (
};
};
+const annotationLayerToExpression = (
+ layer: XYAnnotationLayerConfig,
+ eventAnnotationService: EventAnnotationServiceType
+): Ast => {
+ return {
+ type: 'expression',
+ chain: [
+ {
+ type: 'function',
+ function: 'lens_xy_annotation_layer',
+ arguments: {
+ hide: [Boolean(layer.hide)],
+ layerId: [layer.layerId],
+ layerType: [layerTypes.ANNOTATIONS],
+ annotations: layer.annotations
+ ? layer.annotations.map(
+ (ann): Ast =>
+ eventAnnotationService.toExpression({
+ time: ann.key.timestamp,
+ label: ann.label || defaultAnnotationLabel,
+ textVisibility: ann.textVisibility,
+ icon: ann.icon,
+ lineStyle: ann.lineStyle,
+ lineWidth: ann.lineWidth,
+ color: ann.color,
+ isHidden: Boolean(ann.isHidden),
+ })
+ )
+ : [],
+ },
+ },
+ ],
+ };
+};
+
const dataLayerToExpression = (
layer: ValidLayer,
datasourceLayer: DatasourcePublicAPI,
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 07e411b1993c9..b93cf317e1b2f 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts
@@ -8,7 +8,7 @@
import { getXyVisualization } from './visualization';
import { Position } from '@elastic/charts';
import { Operation, VisualizeEditorContext, Suggestion, OperationDescriptor } from '../types';
-import type { State, XYSuggestion } from './types';
+import type { State, XYState, XYSuggestion } from './types';
import type {
SeriesType,
XYDataLayerConfig,
@@ -23,6 +23,18 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'
import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks';
import { Datatable } from 'src/plugins/expressions';
import { themeServiceMock } from '../../../../../src/core/public/mocks';
+import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks';
+import { EventAnnotationConfig } from 'src/plugins/event_annotation/common';
+
+const exampleAnnotation: EventAnnotationConfig = {
+ id: 'an1',
+ label: 'Event 1',
+ key: {
+ type: 'point_in_time',
+ timestamp: '2022-03-18T08:25:17.140Z',
+ },
+ icon: 'circle',
+};
function exampleState(): State {
return {
@@ -49,6 +61,7 @@ const xyVisualization = getXyVisualization({
fieldFormats: fieldFormatsMock,
useLegacyTimeAxis: false,
kibanaTheme: themeServiceMock.createStartContract(),
+ eventAnnotationService: eventAnnotationServiceMock,
});
describe('xy_visualization', () => {
@@ -149,7 +162,7 @@ describe('xy_visualization', () => {
expect(initialState.layers).toHaveLength(1);
expect((initialState.layers[0] as XYDataLayerConfig).xAccessor).not.toBeDefined();
- expect(initialState.layers[0].accessors).toHaveLength(0);
+ expect((initialState.layers[0] as XYDataLayerConfig).accessors).toHaveLength(0);
expect(initialState).toMatchInlineSnapshot(`
Object {
@@ -227,12 +240,63 @@ describe('xy_visualization', () => {
describe('#getSupportedLayers', () => {
it('should return a double layer types', () => {
- expect(xyVisualization.getSupportedLayers()).toHaveLength(2);
+ expect(xyVisualization.getSupportedLayers()).toHaveLength(3);
});
it('should return the icon for the visualization type', () => {
expect(xyVisualization.getSupportedLayers()[0].icon).not.toBeUndefined();
});
+ describe('annotations', () => {
+ let mockDatasource: ReturnType;
+ let frame: ReturnType;
+ beforeEach(() => {
+ frame = createMockFramePublicAPI();
+ mockDatasource = createMockDatasource('testDatasource');
+
+ frame.datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => {
+ if (accessor === 'a') {
+ return {
+ dataType: 'date',
+ isBucketed: true,
+ scale: 'interval',
+ label: 'date_histogram',
+ isStaticValue: false,
+ hasTimeShift: false,
+ };
+ }
+ return null;
+ });
+
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ rows: [],
+ columns: [],
+ },
+ };
+ });
+ it('when there is no date histogram annotation layer is disabled', () => {
+ const supportedAnnotationLayer = xyVisualization
+ .getSupportedLayers(exampleState())
+ .find((a) => a.type === 'annotations');
+ expect(supportedAnnotationLayer?.disabled).toBeTruthy();
+ });
+ it('for data with date histogram annotation layer is enabled and calculates initial dimensions', () => {
+ const supportedAnnotationLayer = xyVisualization
+ .getSupportedLayers(exampleState(), frame)
+ .find((a) => a.type === 'annotations');
+ expect(supportedAnnotationLayer?.disabled).toBeFalsy();
+ expect(supportedAnnotationLayer?.noDatasource).toBeTruthy();
+ expect(supportedAnnotationLayer?.initialDimensions).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ groupId: 'xAnnotations', columnId: expect.any(String) }),
+ ])
+ );
+ });
+ });
});
describe('#getLayerType', () => {
@@ -358,6 +422,45 @@ describe('xy_visualization', () => {
],
});
});
+
+ describe('annotations', () => {
+ it('should add a dimension to a annotation layer', () => {
+ jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-18T11:01:58.135Z').valueOf());
+ expect(
+ xyVisualization.setDimension({
+ frame,
+ prevState: {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'annotation',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation],
+ },
+ ],
+ },
+ layerId: 'annotation',
+ groupId: 'xAnnotation',
+ columnId: 'newCol',
+ }).layers[0]
+ ).toEqual({
+ layerId: 'annotation',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [
+ exampleAnnotation,
+ {
+ icon: 'triangle',
+ id: 'newCol',
+ key: {
+ timestamp: '2022-04-18T11:01:58.135Z',
+ type: 'point_in_time',
+ },
+ label: 'Event',
+ },
+ ],
+ });
+ });
+ });
});
describe('#updateLayersConfigurationFromContext', () => {
@@ -472,9 +575,10 @@ describe('xy_visualization', () => {
layerId: 'first',
context: newContext,
});
- expect(state?.layers[0]).toHaveProperty('seriesType', 'area');
- expect(state?.layers[0]).toHaveProperty('layerType', 'referenceLine');
- expect(state?.layers[0].yConfig).toStrictEqual([
+ const firstLayer = state?.layers[0] as XYDataLayerConfig;
+ expect(firstLayer).toHaveProperty('seriesType', 'area');
+ expect(firstLayer).toHaveProperty('layerType', 'referenceLine');
+ expect(firstLayer.yConfig).toStrictEqual([
{
axisMode: 'right',
color: '#68BC00',
@@ -695,6 +799,45 @@ describe('xy_visualization', () => {
accessors: [],
});
});
+ it('removes annotation dimension', () => {
+ expect(
+ xyVisualization.removeDimension({
+ frame,
+ prevState: {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: [],
+ },
+ {
+ layerId: 'ann',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation, { ...exampleAnnotation, id: 'an2' }],
+ },
+ ],
+ },
+ layerId: 'ann',
+ columnId: 'an2',
+ }).layers
+ ).toEqual([
+ {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ seriesType: 'area',
+ xAccessor: 'a',
+ accessors: [],
+ },
+ {
+ layerId: 'ann',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation],
+ },
+ ]);
+ });
});
describe('#getConfiguration', () => {
@@ -1069,7 +1212,7 @@ describe('xy_visualization', () => {
it('should support static value', () => {
const state = getStateWithBaseReferenceLine();
- state.layers[0].accessors = [];
+ (state.layers[1] as XYReferenceLineLayerConfig).accessors = [];
(state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined;
expect(
xyVisualization.getConfiguration({
@@ -1082,7 +1225,7 @@ describe('xy_visualization', () => {
it('should return no referenceLine groups for a empty data layer', () => {
const state = getStateWithBaseReferenceLine();
- state.layers[0].accessors = [];
+ (state.layers[0] as XYDataLayerConfig).accessors = [];
(state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined;
const options = xyVisualization.getConfiguration({
@@ -1358,6 +1501,83 @@ describe('xy_visualization', () => {
});
});
+ describe('annotations', () => {
+ beforeEach(() => {
+ frame = createMockFramePublicAPI();
+ mockDatasource = createMockDatasource('testDatasource');
+
+ frame.datasourceLayers = {
+ first: mockDatasource.publicAPIMock,
+ };
+ frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => {
+ if (accessor === 'a') {
+ return {
+ dataType: 'date',
+ isBucketed: true,
+ scale: 'interval',
+ label: 'date_histogram',
+ isStaticValue: false,
+ hasTimeShift: false,
+ };
+ }
+ return null;
+ });
+
+ frame.activeData = {
+ first: {
+ type: 'datatable',
+ rows: [],
+ columns: [],
+ },
+ };
+ });
+
+ function getStateWithAnnotationLayer(): State {
+ return {
+ ...exampleState(),
+ layers: [
+ {
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ seriesType: 'area',
+ splitAccessor: undefined,
+ xAccessor: 'a',
+ accessors: ['b'],
+ },
+ {
+ layerId: 'annotations',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [exampleAnnotation],
+ },
+ ],
+ };
+ }
+
+ it('returns configuration correctly', () => {
+ const state = getStateWithAnnotationLayer();
+ const config = xyVisualization.getConfiguration({
+ state,
+ frame,
+ layerId: 'annotations',
+ });
+ expect(config.groups[0].accessors).toEqual([
+ { color: '#f04e98', columnId: 'an1', triggerIcon: 'color' },
+ ]);
+ expect(config.groups[0].invalid).toEqual(false);
+ });
+
+ it('When data layer is empty, should return invalid state', () => {
+ const state = getStateWithAnnotationLayer();
+ (state.layers[0] as XYDataLayerConfig).xAccessor = undefined;
+ const config = xyVisualization.getConfiguration({
+ state,
+ frame,
+ layerId: 'annotations',
+ });
+ expect(config.groups[0].invalid).toEqual(true);
+ });
+ });
+
describe('color assignment', () => {
function callConfig(layerConfigOverride: Partial) {
const baseState = exampleState();
@@ -1954,4 +2174,87 @@ describe('xy_visualization', () => {
`);
});
});
+ describe('#getUniqueLabels', () => {
+ it('creates unique labels for single annotations layer with repeating labels', async () => {
+ const xyState = {
+ layers: [
+ {
+ layerId: 'layerId',
+ layerType: 'annotations',
+ annotations: [
+ {
+ label: 'Event',
+ id: '1',
+ },
+ {
+ label: 'Event',
+ id: '2',
+ },
+ {
+ label: 'Custom',
+ id: '3',
+ },
+ ],
+ },
+ ],
+ } as XYState;
+
+ expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({
+ '1': 'Event',
+ '2': 'Event [1]',
+ '3': 'Custom',
+ });
+ });
+ it('creates unique labels for multiple annotations layers with repeating labels', async () => {
+ const xyState = {
+ layers: [
+ {
+ layerId: 'layer1',
+ layerType: 'annotations',
+ annotations: [
+ {
+ label: 'Event',
+ id: '1',
+ },
+ {
+ label: 'Event',
+ id: '2',
+ },
+ {
+ label: 'Custom',
+ id: '3',
+ },
+ ],
+ },
+ {
+ layerId: 'layer2',
+ layerType: 'annotations',
+ annotations: [
+ {
+ label: 'Event',
+ id: '4',
+ },
+ {
+ label: 'Event [1]',
+ id: '5',
+ },
+ {
+ label: 'Custom',
+ id: '6',
+ },
+ ],
+ },
+ ],
+ } as XYState;
+
+ expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({
+ '1': 'Event',
+ '2': 'Event [1]',
+ '3': 'Custom',
+ '4': 'Event [2]',
+ '5': 'Event [1] [1]',
+ '6': 'Custom [1]',
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
index c9951c24f8a47..78fd50f7cfece 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx
@@ -13,16 +13,17 @@ import { i18n } from '@kbn/i18n';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { FieldFormatsStart } from 'src/plugins/field_formats/public';
import { ThemeServiceStart } from 'kibana/public';
+import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public';
import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public';
-import type { FillStyle } from '../../common/expressions/xy_chart';
+import type { FillStyle, XYLayerConfig } from '../../common/expressions/xy_chart';
import { getSuggestions } from './xy_suggestions';
import { XyToolbar } from './xy_config_panel';
import { DimensionEditor } from './xy_config_panel/dimension_editor';
import { LayerHeader } from './xy_config_panel/layer_header';
import type { Visualization, AccessorConfig, FramePublicAPI } from '../types';
import { State, visualizationTypes, XYSuggestion } from './types';
-import { SeriesType, XYDataLayerConfig, XYLayerConfig, YAxisMode } from '../../common/expressions';
+import { SeriesType, XYDataLayerConfig, YAxisMode } from '../../common/expressions';
import { layerTypes } from '../../common';
import { isHorizontalChart } from './state_helpers';
import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression';
@@ -34,6 +35,12 @@ import {
getReferenceSupportedLayer,
setReferenceDimension,
} from './reference_line_helpers';
+import {
+ getAnnotationsConfiguration,
+ getAnnotationsSupportedLayer,
+ setAnnotationsDimension,
+ getUniqueLabels,
+} from './annotations/helpers';
import {
checkXAccessorCompatibility,
defaultSeriesType,
@@ -42,7 +49,9 @@ import {
getDescription,
getFirstDataLayer,
getLayersByType,
+ getReferenceLayers,
getVisualizationType,
+ isAnnotationsLayer,
isBucketed,
isDataLayer,
isNumericDynamicMetric,
@@ -54,14 +63,18 @@ import {
import { groupAxesByType } from './axes_configuration';
import { XYState } from '..';
import { ReferenceLinePanel } from './xy_config_panel/reference_line_panel';
+import { DimensionTrigger } from '../shared_components/dimension_trigger';
+import { AnnotationsPanel, defaultAnnotationLabel } from './annotations/config_panel';
export const getXyVisualization = ({
paletteService,
fieldFormats,
useLegacyTimeAxis,
kibanaTheme,
+ eventAnnotationService,
}: {
paletteService: PaletteRegistry;
+ eventAnnotationService: EventAnnotationServiceType;
fieldFormats: FieldFormatsStart;
useLegacyTimeAxis: boolean;
kibanaTheme: ThemeServiceStart;
@@ -155,7 +168,11 @@ export const getXyVisualization = ({
},
getSupportedLayers(state, frame) {
- return [supportedDataLayer, getReferenceSupportedLayer(state, frame)];
+ return [
+ supportedDataLayer,
+ getAnnotationsSupportedLayer(state, frame),
+ getReferenceSupportedLayer(state, frame),
+ ];
},
getConfiguration({ state, frame, layerId }) {
@@ -164,10 +181,18 @@ export const getXyVisualization = ({
return { groups: [] };
}
+ if (isAnnotationsLayer(layer)) {
+ return getAnnotationsConfiguration({ state, frame, layer });
+ }
+
const sortedAccessors: string[] = getSortedAccessors(
frame.datasourceLayers[layer.layerId],
layer
);
+ if (isReferenceLayer(layer)) {
+ return getReferenceConfiguration({ state, frame, layer, sortedAccessors });
+ }
+
const mappedAccessors = getMappedAccessors({
state,
frame,
@@ -177,11 +202,7 @@ export const getXyVisualization = ({
accessors: sortedAccessors,
});
- if (isReferenceLayer(layer)) {
- return getReferenceConfiguration({ state, frame, layer, sortedAccessors, mappedAccessors });
- }
const dataLayers = getDataLayers(state.layers);
-
const isHorizontal = isHorizontalChart(state.layers);
const { left, right } = groupAxesByType([layer], frame.activeData);
// Check locally if it has one accessor OR one accessor per axis
@@ -275,6 +296,9 @@ export const getXyVisualization = ({
if (isReferenceLayer(foundLayer)) {
return setReferenceDimension(props);
}
+ if (isAnnotationsLayer(foundLayer)) {
+ return setAnnotationsDimension(props);
+ }
const newLayer = { ...foundLayer };
if (groupId === 'x') {
@@ -295,7 +319,7 @@ export const getXyVisualization = ({
updateLayersConfigurationFromContext({ prevState, layerId, context }) {
const { chartType, axisPosition, palette, metrics } = context;
const foundLayer = prevState?.layers.find((l) => l.layerId === layerId);
- if (!foundLayer) {
+ if (!foundLayer || !isDataLayer(foundLayer)) {
return prevState;
}
const isReferenceLine = metrics.some((metric) => metric.agg === 'static_value');
@@ -377,7 +401,16 @@ export const getXyVisualization = ({
if (!foundLayer) {
return prevState;
}
- const dataLayers = getDataLayers(prevState.layers);
+ if (isAnnotationsLayer(foundLayer)) {
+ const newLayer = { ...foundLayer };
+ newLayer.annotations = newLayer.annotations.filter(({ id }) => id !== columnId);
+
+ const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l));
+ return {
+ ...prevState,
+ layers: newLayers,
+ };
+ }
const newLayer = { ...foundLayer };
if (isDataLayer(newLayer)) {
if (newLayer.xAccessor === columnId) {
@@ -392,15 +425,15 @@ export const getXyVisualization = ({
newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId);
}
- if (newLayer.yConfig) {
- newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId);
+ if ('yConfig' in newLayer) {
+ newLayer.yConfig = newLayer.yConfig?.filter(({ forAccessor }) => forAccessor !== columnId);
}
let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l));
// check if there's any reference layer and pull it off if all data layers have no dimensions set
// check for data layers if they all still have xAccessors
const groupsAvailable = getGroupsAvailableInData(
- dataLayers,
+ getDataLayers(prevState.layers),
frame.datasourceLayers,
frame?.activeData
);
@@ -410,7 +443,9 @@ export const getXyVisualization = ({
(id) => !groupsAvailable[id]
)
) {
- newLayers = newLayers.filter((layer) => isDataLayer(layer) || layer.accessors.length);
+ newLayers = newLayers.filter(
+ (layer) => isDataLayer(layer) || ('accessors' in layer && layer.accessors.length)
+ );
}
return {
@@ -450,9 +485,12 @@ export const getXyVisualization = ({
const layer = props.state.layers.find((l) => l.layerId === props.layerId)!;
const dimensionEditor = isReferenceLayer(layer) ? (
+ ) : isAnnotationsLayer(layer) ? (
+
) : (
);
+
render(
{dimensionEditor}
@@ -462,8 +500,9 @@ export const getXyVisualization = ({
},
toExpression: (state, layers, attributes) =>
- toExpression(state, layers, paletteService, attributes),
- toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService),
+ toExpression(state, layers, paletteService, attributes, eventAnnotationService),
+ toPreviewExpression: (state, layers) =>
+ toPreviewExpression(state, layers, paletteService, eventAnnotationService),
getErrorMessages(state, datasourceLayers) {
// Data error handling below here
@@ -504,7 +543,7 @@ export const getXyVisualization = ({
// temporary fix for #87068
errors.push(...checkXAccessorCompatibility(state, datasourceLayers));
- for (const layer of state.layers) {
+ for (const layer of getDataLayers(state.layers)) {
const datasourceAPI = datasourceLayers[layer.layerId];
if (datasourceAPI) {
for (const accessor of layer.accessors) {
@@ -540,9 +579,10 @@ export const getXyVisualization = ({
return;
}
- const layers = state.layers;
-
- const filteredLayers = layers.filter(({ accessors }: XYLayerConfig) => accessors.length > 0);
+ const filteredLayers = [
+ ...getDataLayers(state.layers),
+ ...getReferenceLayers(state.layers),
+ ].filter(({ accessors }) => accessors.length > 0);
const accessorsWithArrayValues = [];
for (const layer of filteredLayers) {
const { layerId, accessors } = layer;
@@ -569,6 +609,35 @@ export const getXyVisualization = ({
/>
));
},
+ getUniqueLabels(state) {
+ return getUniqueLabels(state.layers);
+ },
+ renderDimensionTrigger({
+ columnId,
+ label,
+ hideTooltip,
+ invalid,
+ invalidMessage,
+ }: {
+ columnId: string;
+ label?: string;
+ hideTooltip?: boolean;
+ invalid?: boolean;
+ invalidMessage?: string;
+ }) {
+ if (label) {
+ return (
+
+ );
+ }
+ return null;
+ },
});
const getMappedAccessors = ({
@@ -584,7 +653,7 @@ const getMappedAccessors = ({
paletteService: PaletteRegistry;
fieldFormats: FieldFormatsStart;
state: XYState;
- layer: XYLayerConfig;
+ layer: XYDataLayerConfig;
}) => {
let mappedAccessors: AccessorConfig[] = accessors.map((accessor) => ({
columnId: accessor,
@@ -592,7 +661,7 @@ const getMappedAccessors = ({
if (frame.activeData) {
const colorAssignments = getColorAssignments(
- state.layers,
+ getDataLayers(state.layers),
{ tables: frame.activeData },
fieldFormats.deserialize
);
diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx
index 7446c2a06119c..23c2446ca2363 100644
--- a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx
@@ -11,8 +11,12 @@ import { DatasourcePublicAPI, OperationMetadata, VisualizationType } from '../ty
import { State, visualizationTypes, XYState } from './types';
import { isHorizontalChart } from './state_helpers';
import {
+ AnnotationLayerArgs,
+ DataLayerArgs,
SeriesType,
+ XYAnnotationLayerConfig,
XYDataLayerConfig,
+ XYLayerArgs,
XYLayerConfig,
XYReferenceLineLayerConfig,
} from '../../common/expressions';
@@ -130,9 +134,12 @@ export function checkScaleOperation(
export const isDataLayer = (layer: Pick): layer is XYDataLayerConfig =>
layer.layerType === layerTypes.DATA || !layer.layerType;
-export const getDataLayers = (layers: XYLayerConfig[]) =>
+export const getDataLayers = (layers: Array>) =>
(layers || []).filter((layer): layer is XYDataLayerConfig => isDataLayer(layer));
+export const getDataLayersArgs = (layers: XYLayerArgs[]) =>
+ (layers || []).filter((layer): layer is DataLayerArgs => isDataLayer(layer));
+
export const getFirstDataLayer = (layers: XYLayerConfig[]) =>
(layers || []).find((layer): layer is XYDataLayerConfig => isDataLayer(layer));
@@ -140,9 +147,34 @@ export const isReferenceLayer = (
layer: Pick
): layer is XYReferenceLineLayerConfig => layer.layerType === layerTypes.REFERENCELINE;
-export const getReferenceLayers = (layers: XYLayerConfig[]) =>
+export const getReferenceLayers = (layers: Array>) =>
(layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer));
+export const isAnnotationsLayer = (
+ layer: Pick
+): layer is XYAnnotationLayerConfig => layer.layerType === layerTypes.ANNOTATIONS;
+
+export const getAnnotationsLayers = (layers: Array>) =>
+ (layers || []).filter((layer): layer is XYAnnotationLayerConfig => isAnnotationsLayer(layer));
+
+export const getAnnotationsLayersArgs = (layers: XYLayerArgs[]) =>
+ (layers || []).filter((layer): layer is AnnotationLayerArgs => isAnnotationsLayer(layer));
+
+export interface LayerTypeToLayer {
+ [layerTypes.DATA]: (layer: XYDataLayerConfig) => XYDataLayerConfig;
+ [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => XYReferenceLineLayerConfig;
+ [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => XYAnnotationLayerConfig;
+}
+
+export const getLayerTypeOptions = (layer: XYLayerConfig, options: LayerTypeToLayer) => {
+ if (isDataLayer(layer)) {
+ return options[layerTypes.DATA](layer);
+ } else if (isReferenceLayer(layer)) {
+ return options[layerTypes.REFERENCELINE](layer);
+ }
+ return options[layerTypes.ANNOTATIONS](layer);
+};
+
export function getVisualizationType(state: State): VisualizationType | 'mixed' {
if (!state.layers.length) {
return (
@@ -255,6 +287,11 @@ const newLayerFn = {
layerType: layerTypes.REFERENCELINE,
accessors: [],
}),
+ [layerTypes.ANNOTATIONS]: ({ layerId }: { layerId: string }): XYAnnotationLayerConfig => ({
+ layerId,
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [],
+ }),
};
export function newLayerState({
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx
index 8aa2aaf16ae5f..b448ebfbd455e 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx
@@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui';
import type { PaletteRegistry } from 'src/plugins/charts/public';
+import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public';
import type { VisualizationDimensionEditorProps } from '../../types';
import { State } from '../types';
import { FormatFactory } from '../../../common';
@@ -20,7 +21,7 @@ import {
} from '../color_assignment';
import { getSortedAccessors } from '../to_expression';
import { TooltipWrapper } from '../../shared_components';
-import { isReferenceLayer } from '../visualization_helpers';
+import { isReferenceLayer, isAnnotationsLayer, getDataLayers } from '../visualization_helpers';
const tooltipContent = {
auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', {
@@ -62,15 +63,17 @@ export const ColorPicker = ({
if (overwriteColor || !frame.activeData) return overwriteColor;
if (isReferenceLayer(layer)) {
return defaultReferenceLineColor;
+ } else if (isAnnotationsLayer(layer)) {
+ return defaultAnnotationColor;
}
const sortedAccessors: string[] = getSortedAccessors(
- frame.datasourceLayers[layer.layerId],
+ frame.datasourceLayers[layer.layerId] ?? layer.accessors,
layer
);
const colorAssignments = getColorAssignments(
- state.layers,
+ getDataLayers(state.layers),
{ tables: frame.activeData },
formatFactory
);
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx
index 465a627fa33b2..c4e5268cfb8af 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx
@@ -16,8 +16,9 @@ import { trackUiEvent } from '../../lens_ui_telemetry';
import { StaticHeader } from '../../shared_components';
import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public';
import { LensIconChartBarReferenceLine } from '../../assets/chart_bar_reference_line';
+import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations';
import { updateLayer } from '.';
-import { isReferenceLayer } from '../visualization_helpers';
+import { isAnnotationsLayer, isReferenceLayer } from '../visualization_helpers';
export function LayerHeader(props: VisualizationLayerWidgetProps) {
const layer = props.state.layers.find((l) => l.layerId === props.layerId);
@@ -26,6 +27,8 @@ export function LayerHeader(props: VisualizationLayerWidgetProps) {
}
if (isReferenceLayer(layer)) {
return ;
+ } else if (isAnnotationsLayer(layer)) {
+ return ;
}
return ;
}
@@ -41,6 +44,17 @@ function ReferenceLayerHeader() {
);
}
+function AnnotationsLayerHeader() {
+ return (
+
+ );
+}
+
function DataLayerHeader(props: VisualizationLayerWidgetProps) {
const [isPopoverOpen, setPopoverIsOpen] = useState(false);
const { state, layerId } = props;
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx
index f00d60b0dc814..78020034c3d43 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx
@@ -70,6 +70,7 @@ export const ReferenceLinePanel = (
return (
<>
+ {' '}
;
+
+export const euiIconsSet = [
{
value: 'empty',
label: i18n.translate('xpack.lens.xyChart.iconSelect.noIconLabel', {
@@ -70,29 +72,35 @@ const icons = [
},
];
-const IconView = (props: { value?: string; label: string }) => {
+const IconView = (props: { value?: string; label: string; icon?: IconType }) => {
if (!props.value) return null;
return (
-
-
- {` ${props.label}`}
-
+
+
+
+
+ {props.label}
+
);
};
export const IconSelect = ({
value,
onChange,
+ customIconSet = euiIconsSet,
}: {
value?: string;
onChange: (newIcon: string) => void;
+ customIconSet?: IconSet;
}) => {
- const selectedIcon = icons.find((option) => value === option.value) || icons[0];
+ const selectedIcon =
+ customIconSet.find((option) => value === option.value) ||
+ customIconSet.find((option) => option.value === 'empty')!;
return (
{
onChange(selection[0].value!);
@@ -100,7 +108,11 @@ export const IconSelect = ({
singleSelection={{ asPlainText: true }}
renderOption={IconView}
compressed
- prepend={hasIcon(selectedIcon.value) ? : undefined}
+ prepend={
+ hasIcon(selectedIcon.value) ? (
+
+ ) : undefined
+ }
/>
);
};
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx
index db01a027d8fec..766d5462db787 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx
@@ -40,8 +40,8 @@ export const LineStyleSettings = ({
defaultMessage: 'Line',
})}
>
-
-
+
+
{
@@ -49,9 +49,8 @@ export const LineStyleSettings = ({
}}
/>
-
+
void;
isHorizontal: boolean;
+ customIconSet?: IconSet;
}) => {
return (
<>
@@ -133,13 +136,15 @@ export const MarkerDecorationSettings = ({
})}
>
{
setConfig({ icon: newIcon });
}}
/>
- {hasIcon(currentConfig?.icon) || currentConfig?.textVisibility ? (
+ {currentConfig?.iconPosition &&
+ (hasIcon(currentConfig?.icon) || currentConfig?.textVisibility) ? (
{
@@ -533,6 +535,60 @@ describe('xy_suggestions', () => {
);
});
+ test('passes annotation layer without modifying it', () => {
+ const annotationLayer: XYAnnotationLayerConfig = {
+ layerId: 'second',
+ layerType: layerTypes.ANNOTATIONS,
+ annotations: [
+ {
+ id: '1',
+ key: {
+ type: 'point_in_time',
+ timestamp: '2020-20-22',
+ },
+ label: 'annotation',
+ },
+ ],
+ };
+ const currentState: XYState = {
+ legend: { isVisible: true, position: 'bottom' },
+ valueLabels: 'hide',
+ preferredSeriesType: 'bar',
+ fittingFunction: 'None',
+ layers: [
+ {
+ accessors: ['price'],
+ layerId: 'first',
+ layerType: layerTypes.DATA,
+ seriesType: 'bar',
+ splitAccessor: 'date',
+ xAccessor: 'product',
+ },
+ annotationLayer,
+ ],
+ };
+ const suggestions = getSuggestions({
+ table: {
+ isMultiRow: true,
+ columns: [numCol('price'), dateCol('date'), strCol('product')],
+ layerId: 'first',
+ changeType: 'unchanged',
+ },
+ state: currentState,
+ keptLayerIds: [],
+ });
+
+ suggestions.every((suggestion) =>
+ expect(suggestion.state.layers).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ layerType: layerTypes.ANNOTATIONS,
+ }),
+ ])
+ )
+ );
+ });
+
test('includes passed in palette for split charts if specified', () => {
const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' };
const [suggestion] = getSuggestions({
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
index 1578442b52815..bd5a37c206c6c 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
@@ -521,7 +521,10 @@ function buildSuggestion({
const keptLayers = currentState
? currentState.layers
// Remove layers that aren't being suggested
- .filter((layer) => keptLayerIds.includes(layer.layerId))
+ .filter(
+ (layer) =>
+ keptLayerIds.includes(layer.layerId) || layer.layerType === layerTypes.ANNOTATIONS
+ )
// Update in place
.map((layer) => (layer.layerId === layerId ? newLayer : layer))
// Replace the seriesType on all previous layers
diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts
index 84e238b3eb15e..c68fed23a7fdb 100644
--- a/x-pack/plugins/lens/server/expressions/expressions.ts
+++ b/x-pack/plugins/lens/server/expressions/expressions.ts
@@ -12,6 +12,7 @@ import {
yAxisConfig,
dataLayerConfig,
referenceLineLayerConfig,
+ annotationLayerConfig,
formatColumn,
legendConfig,
renameColumns,
@@ -40,6 +41,7 @@ export const setupExpressions = (
yAxisConfig,
dataLayerConfig,
referenceLineLayerConfig,
+ annotationLayerConfig,
formatColumn,
legendConfig,
renameColumns,
diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json
index 583e2963a1ca7..76e25f8b08639 100644
--- a/x-pack/plugins/lens/tsconfig.json
+++ b/x-pack/plugins/lens/tsconfig.json
@@ -1,4 +1,3 @@
-
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
@@ -15,31 +14,86 @@
"../../../typings/**/*"
],
"references": [
- { "path": "../spaces/tsconfig.json" },
- { "path": "../../../src/core/tsconfig.json" },
- { "path": "../task_manager/tsconfig.json" },
- { "path": "../global_search/tsconfig.json"},
- { "path": "../saved_objects_tagging/tsconfig.json"},
- { "path": "../../../src/plugins/data/tsconfig.json"},
- { "path": "../../../src/plugins/data_views/tsconfig.json"},
- { "path": "../../../src/plugins/data_view_field_editor/tsconfig.json"},
- { "path": "../../../src/plugins/charts/tsconfig.json"},
- { "path": "../../../src/plugins/expressions/tsconfig.json"},
- { "path": "../../../src/plugins/navigation/tsconfig.json" },
- { "path": "../../../src/plugins/url_forwarding/tsconfig.json" },
- { "path": "../../../src/plugins/visualizations/tsconfig.json" },
- { "path": "../../../src/plugins/dashboard/tsconfig.json" },
- { "path": "../../../src/plugins/ui_actions/tsconfig.json" },
- { "path": "../../../src/plugins/embeddable/tsconfig.json" },
- { "path": "../../../src/plugins/share/tsconfig.json" },
- { "path": "../../../src/plugins/usage_collection/tsconfig.json" },
- { "path": "../../../src/plugins/saved_objects/tsconfig.json"},
- { "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
- { "path": "../../../src/plugins/kibana_react/tsconfig.json" },
- { "path": "../../../src/plugins/embeddable/tsconfig.json"},
- { "path": "../../../src/plugins/presentation_util/tsconfig.json"},
- { "path": "../../../src/plugins/field_formats/tsconfig.json"},
- { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"},
- { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"}
+ {
+ "path": "../spaces/tsconfig.json"
+ },
+ {
+ "path": "../../../src/core/tsconfig.json"
+ },
+ {
+ "path": "../task_manager/tsconfig.json"
+ },
+ {
+ "path": "../global_search/tsconfig.json"
+ },
+ {
+ "path": "../saved_objects_tagging/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/data/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/data_views/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/data_view_field_editor/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/charts/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/expressions/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/navigation/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/url_forwarding/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/visualizations/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/dashboard/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/ui_actions/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/embeddable/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/share/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/usage_collection/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/saved_objects/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/kibana_utils/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/kibana_react/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/embeddable/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/presentation_util/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/field_formats/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"
+ },
+ {
+ "path": "../../../src/plugins/event_annotation/tsconfig.json"
+ }
]
-}
+}
\ No newline at end of file
diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts
index bb4ad821b39cc..69b157835e882 100644
--- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts
+++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts
@@ -48,9 +48,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
})
);
await waitForNextUpdate();
@@ -86,9 +83,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
@@ -112,7 +106,7 @@ describe('useExceptionLists', () => {
});
});
- test('fetches trusted apps lists if "showTrustedApps" is true', async () => {
+ test('does not fetch specific list id if it is added to the hideLists array', async () => {
const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
await act(async () => {
@@ -120,6 +114,7 @@ describe('useExceptionLists', () => {
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {},
+ hideLists: ['listId-1'],
http: mockKibanaHttpService,
initialPagination: {
page: 1,
@@ -128,9 +123,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: true,
})
);
// NOTE: First `waitForNextUpdate` is initialization
@@ -140,192 +132,7 @@ describe('useExceptionLists', () => {
expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
filters:
- '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)',
- http: mockKibanaHttpService,
- namespaceTypes: 'single,agnostic',
- pagination: { page: 1, perPage: 20 },
- signal: new AbortController().signal,
- });
- });
- });
-
- test('does not fetch trusted apps lists if "showTrustedApps" is false', async () => {
- const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
-
- await act(async () => {
- const { waitForNextUpdate } = renderHook(() =>
- useExceptionLists({
- errorMessage: 'Uh oh',
- filterOptions: {},
- http: mockKibanaHttpService,
- initialPagination: {
- page: 1,
- perPage: 20,
- total: 0,
- },
- namespaceTypes: ['single', 'agnostic'],
- notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
- })
- );
- // NOTE: First `waitForNextUpdate` is initialization
- // Second call applies the params
- await waitForNextUpdate();
- await waitForNextUpdate();
-
- expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
- filters:
- '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)',
- http: mockKibanaHttpService,
- namespaceTypes: 'single,agnostic',
- pagination: { page: 1, perPage: 20 },
- signal: new AbortController().signal,
- });
- });
- });
-
- test('fetches event filters lists if "showEventFilters" is true', async () => {
- const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
-
- await act(async () => {
- const { waitForNextUpdate } = renderHook(() =>
- useExceptionLists({
- errorMessage: 'Uh oh',
- filterOptions: {},
- http: mockKibanaHttpService,
- initialPagination: {
- page: 1,
- perPage: 20,
- total: 0,
- },
- namespaceTypes: ['single', 'agnostic'],
- notifications: mockKibanaNotificationsService,
- showEventFilters: true,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
- })
- );
- // NOTE: First `waitForNextUpdate` is initialization
- // Second call applies the params
- await waitForNextUpdate();
- await waitForNextUpdate();
-
- expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
- filters:
- '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)',
- http: mockKibanaHttpService,
- namespaceTypes: 'single,agnostic',
- pagination: { page: 1, perPage: 20 },
- signal: new AbortController().signal,
- });
- });
- });
-
- test('does not fetch event filters lists if "showEventFilters" is false', async () => {
- const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
-
- await act(async () => {
- const { waitForNextUpdate } = renderHook(() =>
- useExceptionLists({
- errorMessage: 'Uh oh',
- filterOptions: {},
- http: mockKibanaHttpService,
- initialPagination: {
- page: 1,
- perPage: 20,
- total: 0,
- },
- namespaceTypes: ['single', 'agnostic'],
- notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
- })
- );
- // NOTE: First `waitForNextUpdate` is initialization
- // Second call applies the params
- await waitForNextUpdate();
- await waitForNextUpdate();
-
- expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
- filters:
- '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)',
- http: mockKibanaHttpService,
- namespaceTypes: 'single,agnostic',
- pagination: { page: 1, perPage: 20 },
- signal: new AbortController().signal,
- });
- });
- });
-
- test('fetches host isolation exceptions lists if "hostIsolationExceptionsFilter" is true', async () => {
- const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
-
- await act(async () => {
- const { waitForNextUpdate } = renderHook(() =>
- useExceptionLists({
- errorMessage: 'Uh oh',
- filterOptions: {},
- http: mockKibanaHttpService,
- initialPagination: {
- page: 1,
- perPage: 20,
- total: 0,
- },
- namespaceTypes: ['single', 'agnostic'],
- notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: true,
- showTrustedApps: false,
- })
- );
- // NOTE: First `waitForNextUpdate` is initialization
- // Second call applies the params
- await waitForNextUpdate();
- await waitForNextUpdate();
-
- expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
- filters:
- '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)',
- http: mockKibanaHttpService,
- namespaceTypes: 'single,agnostic',
- pagination: { page: 1, perPage: 20 },
- signal: new AbortController().signal,
- });
- });
- });
-
- test('does not fetch host isolation exceptions lists if "showHostIsolationExceptions" is false', async () => {
- const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
-
- await act(async () => {
- const { waitForNextUpdate } = renderHook(() =>
- useExceptionLists({
- errorMessage: 'Uh oh',
- filterOptions: {},
- http: mockKibanaHttpService,
- initialPagination: {
- page: 1,
- perPage: 20,
- total: 0,
- },
- namespaceTypes: ['single', 'agnostic'],
- notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
- })
- );
- // NOTE: First `waitForNextUpdate` is initialization
- // Second call applies the params
- await waitForNextUpdate();
- await waitForNextUpdate();
-
- expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
- filters:
- '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)',
+ '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)',
http: mockKibanaHttpService,
namespaceTypes: 'single,agnostic',
pagination: { page: 1, perPage: 20 },
@@ -345,6 +152,7 @@ describe('useExceptionLists', () => {
created_by: 'Moi',
name: 'Sample Endpoint',
},
+ hideLists: ['listId-1'],
http: mockKibanaHttpService,
initialPagination: {
page: 1,
@@ -353,9 +161,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
@@ -365,7 +170,7 @@ describe('useExceptionLists', () => {
expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
filters:
- '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)',
+ '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)',
http: mockKibanaHttpService,
namespaceTypes: 'single,agnostic',
pagination: { page: 1, perPage: 20 },
@@ -381,16 +186,7 @@ describe('useExceptionLists', () => {
UseExceptionListsProps,
ReturnExceptionLists
>(
- ({
- errorMessage,
- filterOptions,
- http,
- initialPagination,
- namespaceTypes,
- notifications,
- showEventFilters,
- showTrustedApps,
- }) =>
+ ({ errorMessage, filterOptions, http, initialPagination, namespaceTypes, notifications }) =>
useExceptionLists({
errorMessage,
filterOptions,
@@ -398,9 +194,6 @@ describe('useExceptionLists', () => {
initialPagination,
namespaceTypes,
notifications,
- showEventFilters,
- showHostIsolationExceptions: false,
- showTrustedApps,
}),
{
initialProps: {
@@ -414,9 +207,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
},
}
);
@@ -436,9 +226,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
});
// NOTE: Only need one call here because hook already initilaized
await waitForNextUpdate();
@@ -465,9 +252,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
@@ -505,9 +289,6 @@ describe('useExceptionLists', () => {
},
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
- showEventFilters: false,
- showHostIsolationExceptions: false,
- showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx
index 48779569131d6..2fdf0a07f4647 100644
--- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx
+++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx
@@ -27,39 +27,47 @@ export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) {
return (
-
-
-
-
-
-
-
- {noHasDataBoxes.map((box) => (
-
-
-
- ))}
+ {noHasDataBoxes.length > 0 && (
+ <>
+
+
+
+
+
+
+
+ {noHasDataBoxes.map((box) => (
+
+
+
+ ))}
+ >
+ )}
- {noHasDataBoxes.length > 0 && }
+ {noHasDataBoxes.length > 0 && hasDataBoxes.length > 0 && }
-
-
-
-
-
-
-
- {hasDataBoxes.map((box) => (
-
-
-
- ))}
+ {hasDataBoxes.length > 0 && (
+ <>
+
+
+
+
+
+
+
+ {hasDataBoxes.map((box) => (
+
+
+
+ ))}
+ >
+ )}
);
}
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx
new file mode 100644
index 0000000000000..6e79c3691402a
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx
@@ -0,0 +1,63 @@
+/*
+ * 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, screen, fireEvent } from '@testing-library/react';
+import { HasDataContextValue } from '../../../context/has_data_context';
+import * as hasDataHook from '../../../hooks/use_has_data';
+import { ObservabilityStatusProgress } from './observability_status_progress';
+import { I18nProvider } from '@kbn/i18n-react';
+
+describe('ObservabilityStatusProgress', () => {
+ const onViewDetailsClickFn = jest.fn();
+
+ beforeEach(() => {
+ jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({
+ hasDataMap: {
+ apm: { hasData: true, status: 'success' },
+ synthetics: { hasData: true, status: 'success' },
+ infra_logs: { hasData: undefined, status: 'success' },
+ infra_metrics: { hasData: true, status: 'success' },
+ ux: { hasData: undefined, status: 'success' },
+ alert: { hasData: false, status: 'success' },
+ },
+ hasAnyData: true,
+ isAllRequestsComplete: true,
+ onRefreshTimeRange: () => {},
+ forceUpdate: '',
+ } as HasDataContextValue);
+ });
+ it('should render the progress', () => {
+ render(
+
+
+
+ );
+ const progressBar = screen.getByRole('progressbar') as HTMLProgressElement;
+ expect(progressBar).toBeInTheDocument();
+ expect(progressBar.value).toBe(50);
+ });
+
+ it('should call the onViewDetailsCallback when view details button is clicked', () => {
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByText('View details'));
+ expect(onViewDetailsClickFn).toHaveBeenCalled();
+ });
+
+ it('should hide the component when dismiss button is clicked', () => {
+ render(
+
+
+
+ );
+ fireEvent.click(screen.getByText('Dismiss'));
+ expect(screen.queryByTestId('status-progress')).toBe(null);
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx
new file mode 100644
index 0000000000000..81f08537c775f
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx
@@ -0,0 +1,118 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React, { useState, useEffect } from 'react';
+import {
+ EuiPanel,
+ EuiProgress,
+ EuiTitle,
+ EuiButtonEmpty,
+ EuiButton,
+ EuiText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+} from '@elastic/eui';
+import { reduce } from 'lodash';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { useHasData } from '../../../hooks/use_has_data';
+import { useUiTracker } from '../../../hooks/use_track_metric';
+
+const LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY = 'HIDE_GUIDED_SETUP';
+
+interface ObservabilityStatusProgressProps {
+ onViewDetailsClick: () => void;
+}
+export function ObservabilityStatusProgress({
+ onViewDetailsClick,
+}: ObservabilityStatusProgressProps) {
+ const { hasDataMap, isAllRequestsComplete } = useHasData();
+ const trackMetric = useUiTracker({ app: 'observability-overview' });
+ const hideGuidedSetupLocalStorageKey = window.localStorage.getItem(
+ LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY
+ );
+ const [isGuidedSetupHidden, setIsGuidedSetupHidden] = useState(
+ JSON.parse(hideGuidedSetupLocalStorageKey || 'false')
+ );
+ const [progress, setProgress] = useState(0);
+
+ useEffect(() => {
+ const totalCounts = Object.keys(hasDataMap);
+ if (isAllRequestsComplete) {
+ const hasDataCount = reduce(
+ hasDataMap,
+ (result, value) => {
+ return value?.hasData ? result + 1 : result;
+ },
+ 0
+ );
+
+ const percentage = (hasDataCount / totalCounts.length) * 100;
+ setProgress(isFinite(percentage) ? percentage : 0);
+ }
+ }, [isAllRequestsComplete, hasDataMap]);
+
+ const hideGuidedSetup = () => {
+ window.localStorage.setItem(LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY, 'true');
+ setIsGuidedSetupHidden(true);
+ trackMetric({ metric: 'guided_setup_progress_dismiss' });
+ };
+
+ const showDetails = () => {
+ onViewDetailsClick();
+ trackMetric({ metric: 'guided_setup_progress_view_details' });
+ };
+
+ return !isGuidedSetupHidden ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null;
+}
diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts
index b81046df99d28..53b2f68821710 100644
--- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts
+++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts
@@ -6,6 +6,7 @@
*/
import { useEffect, useState, useCallback } from 'react';
+import { isEmpty } from 'lodash';
import { loadRules, Rule } from '../../../triggers_actions_ui/public';
import { RULES_LOAD_ERROR } from '../pages/rules/translations';
import { FetchRulesProps } from '../pages/rules/types';
@@ -19,7 +20,13 @@ interface RuleState {
totalItemCount: number;
}
-export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }: FetchRulesProps) {
+export function useFetchRules({
+ searchText,
+ ruleLastResponseFilter,
+ setPage,
+ page,
+ sort,
+}: FetchRulesProps) {
const { http } = useKibana().services;
const [rulesState, setRulesState] = useState({
@@ -29,6 +36,9 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }
totalItemCount: 0,
});
+ const [noData, setNoData] = useState(true);
+ const [initialLoad, setInitialLoad] = useState(true);
+
const fetchRules = useCallback(async () => {
setRulesState((oldState) => ({ ...oldState, isLoading: true }));
@@ -47,10 +57,18 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }
data: response.data,
totalItemCount: response.total,
}));
+
+ if (!response.data?.length && page.index > 0) {
+ setPage({ ...page, index: 0 });
+ }
+ const isFilterApplied = !(isEmpty(searchText) && isEmpty(ruleLastResponseFilter));
+
+ setNoData(response.data.length === 0 && !isFilterApplied);
} catch (_e) {
setRulesState((oldState) => ({ ...oldState, isLoading: false, error: RULES_LOAD_ERROR }));
}
- }, [http, page, searchText, ruleLastResponseFilter, sort]);
+ setInitialLoad(false);
+ }, [http, page, setPage, searchText, ruleLastResponseFilter, sort]);
useEffect(() => {
fetchRules();
}, [fetchRules]);
@@ -59,5 +77,7 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }
rulesState,
reload: fetchRules,
setRulesState,
+ noData,
+ initialLoad,
};
}
diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx
index cf6ae92d1b9c8..939223feb87c0 100644
--- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx
+++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx
@@ -44,6 +44,7 @@ interface RuleStatsState {
disabled: number;
muted: number;
error: number;
+ snoozed: number;
}
export interface TopAlert {
@@ -90,6 +91,7 @@ function AlertsPage() {
disabled: 0,
muted: 0,
error: 0,
+ snoozed: 0,
});
useEffect(() => {
@@ -111,18 +113,21 @@ function AlertsPage() {
const response = await loadRuleAggregations({
http,
});
- const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus } = response;
- if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus) {
+ const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } =
+ response;
+ if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus && ruleSnoozedStatus) {
const total = Object.values(ruleExecutionStatus).reduce((acc, value) => acc + value, 0);
const { disabled } = ruleEnabledStatus;
const { muted } = ruleMutedStatus;
const { error } = ruleExecutionStatus;
+ const { snoozed } = ruleSnoozedStatus;
setRuleStats({
...ruleStats,
total,
disabled,
muted,
error,
+ snoozed,
});
}
setRuleStatsLoading(false);
@@ -263,9 +268,9 @@ function AlertsPage() {
data-test-subj="statDisabled"
/>,
;
@@ -145,6 +146,7 @@ export function OverviewPage({ routeParams }: Props) {
{hasData && (
<>
+ setIsFlyoutVisible(true)} />
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx
index 2b1f831256910..cbde68ea27eb4 100644
--- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx
+++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx
@@ -6,8 +6,7 @@
*/
import React from 'react';
-import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiBadge } from '@elastic/eui';
-import { FormattedMessage } from '@kbn/i18n-react';
+import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
import { RuleNameProps } from '../types';
import { useKibana } from '../../../utils/kibana_react';
@@ -34,17 +33,5 @@ export function Name({ name, rule }: RuleNameProps) {
);
- return (
- <>
- {link}
- {rule.enabled && rule.muteAll && (
-
-
-
- )}
- >
- );
+ return <>{link}>;
}
diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx
new file mode 100644
index 0000000000000..b9c0e24160004
--- /dev/null
+++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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 { FormattedMessage } from '@kbn/i18n-react';
+import React from 'react';
+import { EuiButton, EuiEmptyPrompt, EuiLink, EuiButtonEmpty, EuiPageTemplate } from '@elastic/eui';
+
+export function NoDataPrompt({
+ onCTAClicked,
+ documentationLink,
+}: {
+ onCTAClicked: () => void;
+ documentationLink: string;
+}) {
+ return (
+
+
+
+
+ }
+ body={
+
+
+
+ }
+ actions={[
+
+
+ ,
+
+
+ Documentation
+
+ ,
+ ]}
+ />
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx
new file mode 100644
index 0000000000000..edfe1c6840d8b
--- /dev/null
+++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { FormattedMessage } from '@kbn/i18n-react';
+import React from 'react';
+import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui';
+
+export function NoPermissionPrompt() {
+ return (
+
+
+
+
+ }
+ body={
+
+
+
+ }
+ />
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/pages/rules/components/status.tsx b/x-pack/plugins/observability/public/pages/rules/components/status.tsx
index abc2dc8bfa492..612d6f8f30bdd 100644
--- a/x-pack/plugins/observability/public/pages/rules/components/status.tsx
+++ b/x-pack/plugins/observability/public/pages/rules/components/status.tsx
@@ -5,19 +5,28 @@
* 2.0.
*/
-import React from 'react';
+import React, { useMemo } from 'react';
import { EuiBadge } from '@elastic/eui';
+import { noop } from 'lodash/fp';
import { StatusProps } from '../types';
import { statusMap } from '../config';
+import { RULES_CHANGE_STATUS } from '../translations';
-export function Status({ type, onClick }: StatusProps) {
+export function Status({ type, disabled, onClick }: StatusProps) {
+ const props = useMemo(
+ () => ({
+ color: statusMap[type].color,
+ ...(!disabled ? { onClick } : { onClick: noop }),
+ ...(!disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}),
+ ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }),
+ }),
+ [disabled, onClick, type]
+ );
return (
{statusMap[type].label}
diff --git a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx
index 49761d7c43154..c7bd29d85b17a 100644
--- a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx
+++ b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx
@@ -18,19 +18,26 @@ import { statusMap } from '../config';
export function StatusContext({
item,
+ disabled = false,
onStatusChanged,
enableRule,
disableRule,
muteRule,
+ unMuteRule,
}: StatusContextProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]);
- const currentStatus = item.enabled ? RuleStatus.enabled : RuleStatus.disabled;
+ let currentStatus: RuleStatus;
+ if (item.enabled) {
+ currentStatus = item.muteAll ? RuleStatus.snoozed : RuleStatus.enabled;
+ } else {
+ currentStatus = RuleStatus.disabled;
+ }
const popOverButton = useMemo(
- () => ,
- [currentStatus, togglePopover]
+ () => ,
+ [disabled, currentStatus, togglePopover]
);
const onContextMenuItemClick = useCallback(
@@ -41,15 +48,30 @@ export function StatusContext({
if (status === RuleStatus.enabled) {
await enableRule({ ...item, enabled: true });
+ if (item.muteAll) {
+ await unMuteRule({ ...item, muteAll: false });
+ }
} else if (status === RuleStatus.disabled) {
await disableRule({ ...item, enabled: false });
+ } else if (status === RuleStatus.snoozed) {
+ await muteRule({ ...item, muteAll: true });
}
setIsUpdating(false);
onStatusChanged(status);
}
},
- [item, togglePopover, enableRule, disableRule, currentStatus, onStatusChanged]
+ [
+ item,
+ togglePopover,
+ enableRule,
+ disableRule,
+ muteRule,
+ unMuteRule,
+ currentStatus,
+ onStatusChanged,
+ ]
);
+
const panelItems = useMemo(
() =>
Object.values(RuleStatus).map((status: RuleStatus) => (
@@ -57,6 +79,7 @@ export function StatusContext({
icon={status === currentStatus ? 'check' : 'empty'}
key={status}
onClick={() => onContextMenuItemClick(status)}
+ disabled={status === RuleStatus.snoozed && currentStatus === RuleStatus.disabled}
>
{statusMap[status].label}
diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts
index afff097776e19..736f538ee7b21 100644
--- a/x-pack/plugins/observability/public/pages/rules/config.ts
+++ b/x-pack/plugins/observability/public/pages/rules/config.ts
@@ -13,6 +13,9 @@ import {
RULE_STATUS_PENDING,
RULE_STATUS_UNKNOWN,
RULE_STATUS_WARNING,
+ RULE_STATUS_ENABLED,
+ RULE_STATUS_DISABLED,
+ RULE_STATUS_SNOOZED_INDEFINITELY,
} from './translations';
import { AlertExecutionStatuses } from '../../../../alerting/common';
import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public';
@@ -20,11 +23,15 @@ import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/p
export const statusMap: Status = {
[RuleStatus.enabled]: {
color: 'primary',
- label: 'Enabled',
+ label: RULE_STATUS_ENABLED,
},
[RuleStatus.disabled]: {
color: 'default',
- label: 'Disabled',
+ label: RULE_STATUS_DISABLED,
+ },
+ [RuleStatus.snoozed]: {
+ color: 'warning',
+ label: RULE_STATUS_SNOOZED_INDEFINITELY,
},
};
@@ -93,3 +100,8 @@ export function convertRulesToTableItems(
enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense,
}));
}
+
+type Capabilities = Record;
+
+export const hasExecuteActionsCapability = (capabilities: Capabilities) =>
+ capabilities?.actions?.execute;
diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx
index 8c44fa90fb3d1..21664ca63507d 100644
--- a/x-pack/plugins/observability/public/pages/rules/index.tsx
+++ b/x-pack/plugins/observability/public/pages/rules/index.tsx
@@ -32,6 +32,9 @@ import { ExecutionStatus } from './components/execution_status';
import { LastRun } from './components/last_run';
import { EditRuleFlyout } from './components/edit_rule_flyout';
import { DeleteModalConfirmation } from './components/delete_modal_confirmation';
+import { NoDataPrompt } from './components/prompts/no_data_prompt';
+import { NoPermissionPrompt } from './components/prompts/no_permission_prompt';
+import { CenterJustifiedSpinner } from './components/center_justified_spinner';
import {
deleteRules,
RuleTableItem,
@@ -39,6 +42,7 @@ import {
disableRule,
muteRule,
useLoadRuleTypes,
+ unmuteRule,
} from '../../../../triggers_actions_ui/public';
import { AlertExecutionStatus, ALERTS_FEATURE_ID } from '../../../../alerting/common';
import { Pagination } from './types';
@@ -46,6 +50,7 @@ import {
DEFAULT_SEARCH_PAGE_SIZE,
convertRulesToTableItems,
OBSERVABILITY_SOLUTIONS,
+ hasExecuteActionsCapability,
} from './config';
import {
LAST_RESPONSE_COLUMN_TITLE,
@@ -73,9 +78,12 @@ export function RulesPage() {
http,
docLinks,
triggersActionsUi,
+ application: { capabilities },
notifications: { toasts },
} = useKibana().services;
-
+ const documentationLink = docLinks.links.alerting.guide;
+ const ruleTypeRegistry = triggersActionsUi.ruleTypeRegistry;
+ const canExecuteActions = hasExecuteActionsCapability(capabilities);
const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE });
const [sort, setSort] = useState['sort']>({
field: 'name',
@@ -90,6 +98,9 @@ export function RulesPage() {
const [rulesToDelete, setRulesToDelete] = useState([]);
const [createRuleFlyoutVisibility, setCreateRuleFlyoutVisibility] = useState(false);
+ const isRuleTypeEditableInContext = (ruleTypeId: string) =>
+ ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false;
+
const onRuleEdit = (ruleItem: RuleTableItem) => {
setCurrentRuleToEdit(ruleItem);
};
@@ -102,14 +113,22 @@ export function RulesPage() {
setRefreshInterval(refreshIntervalChanged);
};
- const { rulesState, setRulesState, reload } = useFetchRules({
+ const { rulesState, setRulesState, reload, noData, initialLoad } = useFetchRules({
searchText,
ruleLastResponseFilter,
page,
+ setPage,
sort,
});
const { data: rules, totalItemCount, error } = rulesState;
- const { ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS });
+ const { ruleTypeIndex, ruleTypes } = useLoadRuleTypes({
+ filteredSolutions: OBSERVABILITY_SOLUTIONS,
+ });
+ const authorizedRuleTypes = [...ruleTypes.values()];
+
+ const authorizedToCreateAnyRules = authorizedRuleTypes.some(
+ (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all
+ );
useEffect(() => {
const interval = setInterval(() => {
@@ -161,11 +180,13 @@ export function RulesPage() {
render: (_enabled: boolean, item: RuleTableItem) => {
return (
reload()}
enableRule={async () => await enableRule({ http, id: item.id })}
disableRule={async () => await disableRule({ http, id: item.id })}
muteRule={async () => await muteRule({ http, id: item.id })}
+ unMuteRule={async () => await unmuteRule({ http, id: item.id })}
/>
);
},
@@ -180,6 +201,9 @@ export function RulesPage() {
{
+ if (noData && !rulesState.isLoading) {
+ return authorizedToCreateAnyRules ? (
+ setCreateRuleFlyoutVisibility(true)}
+ />
+ ) : (
+
+ );
+ }
+ if (initialLoad) {
+ return ;
+ }
+ return (
+ <>
+
+
+ {
+ setInputText(e.target.value);
+ if (e.target.value === '') {
+ setSearchText(e.target.value);
+ }
+ }}
+ onKeyUp={(e) => {
+ if (e.keyCode === ENTER_KEY) {
+ setSearchText(inputText);
+ }
+ }}
+ placeholder={SEARCH_PLACEHOLDER}
+ />
+
+
+ setRuleLastResponseFilter(ids)}
+ />
+
+
+
+
+
+ ,
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setPage(index)}
+ sort={sort}
+ onSortChange={(changedSort) => {
+ setSort(changedSort);
+ }}
+ />
+
+
+ >
+ );
+ };
+
return (
),
rightSideItems: [
- setCreateRuleFlyoutVisibility(true)}
- >
-
- ,
+ authorizedToCreateAnyRules && (
+ setCreateRuleFlyoutVisibility(true)}
+ >
+
+
+ ),
-
-
- {
- setInputText(e.target.value);
- if (e.target.value === '') {
- setSearchText(e.target.value);
- }
- }}
- onKeyUp={(e) => {
- if (e.keyCode === ENTER_KEY) {
- setSearchText(inputText);
- }
- }}
- placeholder={SEARCH_PLACEHOLDER}
- />
-
-
- setRuleLastResponseFilter(ids)}
- />
-
-
-
-
-
- ,
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setPage(index)}
- sort={sort}
- onSortChange={(changedSort) => {
- setSort(changedSort);
- }}
- />
-
-
+ {getRulesTable()}
{error &&
toasts.addDanger({
title: error,
diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts
index b72d03bf8e566..36f8ff62f1a4c 100644
--- a/x-pack/plugins/observability/public/pages/rules/translations.ts
+++ b/x-pack/plugins/observability/public/pages/rules/translations.ts
@@ -53,6 +53,27 @@ export const RULE_STATUS_WARNING = i18n.translate(
}
);
+export const RULE_STATUS_ENABLED = i18n.translate(
+ 'xpack.observability.rules.rulesTable.ruleStatusEnabled',
+ {
+ defaultMessage: 'Enabled',
+ }
+);
+
+export const RULE_STATUS_DISABLED = i18n.translate(
+ 'xpack.observability.rules.rulesTable.ruleStatusDisabled',
+ {
+ defaultMessage: 'Disabled',
+ }
+);
+
+export const RULE_STATUS_SNOOZED_INDEFINITELY = i18n.translate(
+ 'xpack.observability.rules.rulesTable.ruleStatusSnoozedIndefinitely',
+ {
+ defaultMessage: 'Snoozed indefinitely',
+ }
+);
+
export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate(
'xpack.observability.rules.rulesTable.columns.lastResponseTitle',
{
@@ -144,6 +165,13 @@ export const SEARCH_PLACEHOLDER = i18n.translate(
{ defaultMessage: 'Search' }
);
+export const RULES_CHANGE_STATUS = i18n.translate(
+ 'xpack.observability.rules.rulesTable.changeStatusAriaLabel',
+ {
+ defaultMessage: 'Change status',
+ }
+);
+
export const confirmModalText = (
numIdsToDelete: number,
singleTitle: string,
diff --git a/x-pack/plugins/observability/public/pages/rules/types.ts b/x-pack/plugins/observability/public/pages/rules/types.ts
index 23443890ad8fa..1a15cf3d9cef2 100644
--- a/x-pack/plugins/observability/public/pages/rules/types.ts
+++ b/x-pack/plugins/observability/public/pages/rules/types.ts
@@ -4,17 +4,20 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
+import { Dispatch, SetStateAction } from 'react';
import { EuiTableSortingType, EuiBasicTableColumn } from '@elastic/eui';
import { AlertExecutionStatus } from '../../../../alerting/common';
import { RuleTableItem, Rule } from '../../../../triggers_actions_ui/public';
export interface StatusProps {
type: RuleStatus;
+ disabled: boolean;
onClick: () => void;
}
export enum RuleStatus {
enabled = 'enabled',
disabled = 'disabled',
+ snoozed = 'snoozed',
}
export type Status = Record<
@@ -27,10 +30,12 @@ export type Status = Record<
export interface StatusContextProps {
item: RuleTableItem;
+ disabled: boolean;
onStatusChanged: (status: RuleStatus) => void;
enableRule: (rule: Rule) => Promise;
disableRule: (rule: Rule) => Promise;
muteRule: (rule: Rule) => Promise;
+ unMuteRule: (rule: Rule) => Promise;
}
export interface StatusFilterProps {
@@ -65,6 +70,7 @@ export interface FetchRulesProps {
searchText: string | undefined;
ruleLastResponseFilter: string[];
page: Pagination;
+ setPage: Dispatch>;
sort: EuiTableSortingType['sort'];
}
diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts
index 3d2505ed80513..9d483b63ac0a9 100644
--- a/x-pack/plugins/observability/public/plugin.ts
+++ b/x-pack/plugins/observability/public/plugin.ts
@@ -47,6 +47,7 @@ import { updateGlobalNavigation } from './update_global_navigation';
import { getExploratoryViewEmbeddable } from './components/shared/exploratory_view/embeddable';
import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';
import { createUseRulesLink } from './hooks/create_use_rules_link';
+import getAppDataView from './utils/observability_data_views/get_app_data_view';
export type ObservabilityPublicSetup = ReturnType;
@@ -280,6 +281,7 @@ export class Plugin
PageTemplate,
},
createExploratoryViewUrl,
+ getAppDataView: getAppDataView(pluginsStart.dataViews),
ExploratoryViewEmbeddable: getExploratoryViewEmbeddable(coreStart, pluginsStart),
useRulesLink: createUseRulesLink(config.unsafe.rules.enabled),
};
diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts
new file mode 100644
index 0000000000000..4b4b03412c0c7
--- /dev/null
+++ b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { AppDataType } from '../../components/shared/exploratory_view/types';
+import type { DataViewsPublicPluginStart } from '../../../../../../src/plugins/data_views/public';
+
+const getAppDataView = (data: DataViewsPublicPluginStart) => {
+ return async (appId: AppDataType, indexPattern?: string) => {
+ try {
+ const { ObservabilityDataViews } = await import('./observability_data_views');
+
+ const obsvIndexP = new ObservabilityDataViews(data);
+ return await obsvIndexP.getDataView(appId, indexPattern);
+ } catch (e) {
+ return null;
+ }
+ };
+};
+
+// eslint-disable-next-line import/no-default-export
+export default getAppDataView;
diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts
index 8a74482bb14ca..86ce6cd587213 100644
--- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts
+++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts
@@ -176,3 +176,6 @@ export class ObservabilityDataViews {
}
}
}
+
+// eslint-disable-next-line import/no-default-export
+export default ObservabilityDataViews;
diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts
similarity index 97%
rename from x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts
rename to x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts
index 4f9fb4304fd28..11a904526d314 100644
--- a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts
+++ b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts
@@ -11,8 +11,9 @@ import { addIntegration } from '../../tasks/integrations';
import { login } from '../../tasks/login';
// import { findAndClickButton, findFormFieldByRowsLabelAndType } from '../../tasks/live_query';
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
+import { DEFAULT_POLICY } from '../../screens/fleet';
-describe('Super User - Add Integration', () => {
+describe('ALL - Add Integration', () => {
const integration = 'Osquery Manager';
before(() => {
runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query');
@@ -65,7 +66,7 @@ describe('Super User - Add Integration', () => {
it('add integration', () => {
cy.visit(FLEET_AGENT_POLICIES);
- cy.contains('Default Fleet Server policy').click();
+ cy.contains(DEFAULT_POLICY).click();
cy.contains('Add integration').click();
cy.contains(integration).click();
addIntegration();
diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts
similarity index 100%
rename from x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts
rename to x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts
diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts
similarity index 96%
rename from x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts
rename to x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts
index 5c21f29b650e7..46d927329aa98 100644
--- a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts
+++ b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts
@@ -9,7 +9,7 @@ import { navigateTo } from '../../tasks/navigation';
import { login } from '../../tasks/login';
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
-describe('SuperUser - Delete ECS Mappings', () => {
+describe('ALL - Delete ECS Mappings', () => {
const SAVED_QUERY_ID = 'Saved-Query-Id';
before(() => {
diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts
similarity index 73%
rename from x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts
rename to x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts
index f979f793873f1..d6af17596d89a 100644
--- a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts
+++ b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts
@@ -15,8 +15,10 @@ import {
typeInECSFieldInput,
typeInOsqueryFieldInput,
} from '../../tasks/live_query';
+import { RESULTS_TABLE_CELL_WRRAPER } from '../../screens/live_query';
+import { getAdvancedButton } from '../../screens/integrations';
-describe('Super User - Live Query', () => {
+describe('ALL - Live Query', () => {
beforeEach(() => {
login();
navigateTo('/app/osquery');
@@ -31,23 +33,25 @@ describe('Super User - Live Query', () => {
// checking submit by clicking cmd+enter
inputQuery(cmd);
checkResults();
- cy.react('EuiDataGridHeaderCellWrapper', {
+ cy.contains('View in Discover').should('exist');
+ cy.contains('View in Lens').should('exist');
+ cy.react(RESULTS_TABLE_CELL_WRRAPER, {
props: { id: 'osquery.days.number', index: 1 },
});
- cy.react('EuiDataGridHeaderCellWrapper', {
+ cy.react(RESULTS_TABLE_CELL_WRRAPER, {
props: { id: 'osquery.hours.number', index: 2 },
});
- cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click();
+ getAdvancedButton().click();
typeInECSFieldInput('message{downArrow}{enter}');
typeInOsqueryFieldInput('days{downArrow}{enter}');
submitQuery();
checkResults();
- cy.react('EuiDataGridHeaderCellWrapper', {
+ cy.react(RESULTS_TABLE_CELL_WRRAPER, {
props: { id: 'message', index: 1 },
});
- cy.react('EuiDataGridHeaderCellWrapper', {
+ cy.react(RESULTS_TABLE_CELL_WRRAPER, {
props: { id: 'osquery.days.number', index: 2 },
}).react('EuiIconIndexMapping');
});
diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts
similarity index 97%
rename from x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts
rename to x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts
index f64e6b31ae7a5..ba71e75d9ea7b 100644
--- a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts
+++ b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts
@@ -10,7 +10,7 @@ import { login } from '../../tasks/login';
import { checkResults, inputQuery, submitQuery } from '../../tasks/live_query';
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
-describe('Super User - Metrics', () => {
+describe('ALL - Metrics', () => {
beforeEach(() => {
login();
navigateTo('/app/osquery');
diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts
similarity index 90%
rename from x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts
rename to x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts
index fd04d0a62b160..eafe36874244e 100644
--- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts
+++ b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts
@@ -16,8 +16,10 @@ import { login } from '../../tasks/login';
import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
import { preparePack } from '../../tasks/packs';
import { addIntegration, closeModalIfVisible } from '../../tasks/integrations';
+import { DEFAULT_POLICY } from '../../screens/fleet';
+import { getSavedQueriesDropdown } from '../../screens/live_query';
-describe('SuperUser - Packs', () => {
+describe('ALL - Packs', () => {
const integration = 'Osquery Manager';
const SAVED_QUERY_ID = 'Saved-Query-Id';
const PACK_NAME = 'Pack-name';
@@ -47,21 +49,15 @@ describe('SuperUser - Packs', () => {
findAndClickButton('Add pack');
findFormFieldByRowsLabelAndType('Name', PACK_NAME);
findFormFieldByRowsLabelAndType('Description (optional)', 'Pack description');
- findFormFieldByRowsLabelAndType(
- 'Scheduled agent policies (optional)',
- 'Default Fleet Server policy'
- );
+ findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', DEFAULT_POLICY);
cy.react('List').first().click();
findAndClickButton('Add query');
cy.contains('Attach next query');
- cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } })
- .click()
- .type(SAVED_QUERY_ID);
- cy.react('List').first().click();
+ getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`);
cy.react('EuiFormRow', { props: { label: 'Interval (s)' } })
.click()
.clear()
- .type('10');
+ .type('500');
cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click();
cy.react('EuiTableRow').contains(SAVED_QUERY_ID);
findAndClickButton('Save pack');
@@ -94,10 +90,7 @@ describe('SuperUser - Packs', () => {
findAndClickButton('Add query');
cy.contains('Attach next query');
cy.contains('ID must be unique').should('not.exist');
- cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } })
- .click()
- .type(SAVED_QUERY_ID);
- cy.react('List').first().click();
+ getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`);
cy.contains('ID must be unique').should('exist');
cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click();
});
@@ -175,9 +168,7 @@ describe('SuperUser - Packs', () => {
findAndClickButton('Add query');
- cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } })
- .click()
- .type('Multiple {downArrow} {enter}');
+ getSavedQueriesDropdown().click().type('Multiple {downArrow} {enter}');
cy.contains('Custom key/value pairs');
cy.contains('Days of uptime');
cy.contains('List of keywords used to tag each');
@@ -185,9 +176,7 @@ describe('SuperUser - Packs', () => {
cy.contains('Client network address.');
cy.contains('Total uptime seconds');
- cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } })
- .click()
- .type('NOMAPPING {downArrow} {enter}');
+ getSavedQueriesDropdown().click().type('NOMAPPING {downArrow} {enter}');
cy.contains('Custom key/value pairs').should('not.exist');
cy.contains('Days of uptime').should('not.exist');
cy.contains('List of keywords used to tag each').should('not.exist');
@@ -195,9 +184,7 @@ describe('SuperUser - Packs', () => {
cy.contains('Client network address.').should('not.exist');
cy.contains('Total uptime seconds').should('not.exist');
- cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } })
- .click()
- .type('ONE_MAPPING {downArrow} {enter}');
+ getSavedQueriesDropdown().click().type('ONE_MAPPING {downArrow} {enter}');
cy.contains('Name of the continent');
cy.contains('Seconds of uptime');
diff --git a/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts
new file mode 100644
index 0000000000000..4e48e819ac0ab
--- /dev/null
+++ b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { navigateTo } from '../../tasks/navigation';
+
+import { login } from '../../tasks/login';
+import { getSavedQueriesComplexTest } from '../../tasks/saved_queries';
+
+const SAVED_QUERY_ID = 'Saved-Query-Id';
+const SAVED_QUERY_DESCRIPTION = 'Test saved query description';
+
+describe('ALL - Saved queries', () => {
+ beforeEach(() => {
+ login();
+ navigateTo('/app/osquery');
+ });
+
+ getSavedQueriesComplexTest(SAVED_QUERY_ID, SAVED_QUERY_DESCRIPTION);
+});
diff --git a/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts
new file mode 100644
index 0000000000000..d3a00f970322b
--- /dev/null
+++ b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { login } from '../../tasks/login';
+import { navigateTo } from '../../tasks/navigation';
+import { ROLES } from '../../test';
+import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
+
+describe('Reader - only READ', () => {
+ const SAVED_QUERY_ID = 'Saved-Query-Id';
+
+ beforeEach(() => {
+ login(ROLES.reader);
+ });
+ before(() => {
+ runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query');
+ });
+
+ after(() => {
+ runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query');
+ });
+
+ it('should not be able to add nor run saved queries', () => {
+ navigateTo('/app/osquery/saved_queries');
+ cy.waitForReact(1000);
+ cy.contains(SAVED_QUERY_ID);
+ cy.contains('Add saved query').should('be.disabled');
+ cy.react('PlayButtonComponent', {
+ props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } },
+ options: { timeout: 3000 },
+ }).should('not.exist');
+ cy.react('CustomItemAction', {
+ props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } },
+ }).click();
+ cy.react('EuiFormRow', { props: { label: 'ID' } })
+ .getBySel('input')
+ .should('be.disabled');
+ cy.react('EuiFormRow', { props: { label: 'Description (optional)' } })
+ .getBySel('input')
+ .should('be.disabled');
+
+ cy.contains('Update query').should('not.exist');
+ cy.contains(`Delete query`).should('not.exist');
+ });
+ it('should not be able to play in live queries history', () => {
+ navigateTo('/app/osquery/live_queries');
+ cy.waitForReact(1000);
+ cy.contains('New live query').should('be.disabled');
+ cy.contains('select * from uptime');
+ cy.react('EuiIconPlay', { options: { timeout: 3000 } }).should('not.exist');
+ cy.react('ActionTableResultsButton').should('exist');
+ });
+ it('should not be able to add nor edit packs', () => {
+ const PACK_NAME = 'removing-pack';
+
+ navigateTo('/app/osquery/packs');
+ cy.waitForReact(1000);
+ cy.contains('Add pack').should('be.disabled');
+ cy.react('ActiveStateSwitchComponent', {
+ props: { item: { attributes: { name: PACK_NAME } } },
+ })
+ .find('button')
+ .should('be.disabled');
+ cy.contains(PACK_NAME).click();
+ cy.contains(`${PACK_NAME} details`);
+ cy.contains('Edit').should('be.disabled');
+ cy.react('CustomItemAction', {
+ props: { index: 0, item: { id: SAVED_QUERY_ID } },
+ options: { timeout: 3000 },
+ }).should('not.exist');
+ cy.react('CustomItemAction', {
+ props: { index: 1, item: { id: SAVED_QUERY_ID } },
+ options: { timeout: 3000 },
+ }).should('not.exist');
+ });
+});
diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts
new file mode 100644
index 0000000000000..64d72c92dda04
--- /dev/null
+++ b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 { login } from '../../tasks/login';
+import { navigateTo } from '../../tasks/navigation';
+import { ROLES } from '../../test';
+import { checkResults, selectAllAgents, submitQuery } from '../../tasks/live_query';
+import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
+import { getSavedQueriesDropdown, LIVE_QUERY_EDITOR } from '../../screens/live_query';
+
+describe('T1 Analyst - READ + runSavedQueries ', () => {
+ const SAVED_QUERY_ID = 'Saved-Query-Id';
+
+ beforeEach(() => {
+ login(ROLES.t1_analyst);
+ });
+ before(() => {
+ runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query');
+ });
+
+ after(() => {
+ runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query');
+ });
+
+ it('should be able to run saved queries but not add new ones', () => {
+ navigateTo('/app/osquery/saved_queries');
+ cy.waitForReact(1000);
+ cy.contains(SAVED_QUERY_ID);
+ cy.contains('Add saved query').should('be.disabled');
+ cy.react('PlayButtonComponent', {
+ props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } },
+ })
+ .should('not.be.disabled')
+ .click();
+ selectAllAgents();
+ cy.contains('select * from uptime;');
+ submitQuery();
+ checkResults();
+ cy.contains('View in Discover').should('not.exist');
+ cy.contains('View in Lens').should('not.exist');
+ });
+ it('should be able to play in live queries history', () => {
+ navigateTo('/app/osquery/live_queries');
+ cy.waitForReact(1000);
+ cy.contains('New live query').should('not.be.disabled');
+ cy.contains('select * from uptime');
+ cy.wait(1000);
+ cy.react('EuiTableBody').first().react('DefaultItemAction').first().click();
+ selectAllAgents();
+ cy.contains(SAVED_QUERY_ID);
+ submitQuery();
+ checkResults();
+ });
+ it('should be able to use saved query in a new query', () => {
+ navigateTo('/app/osquery/live_queries');
+ cy.waitForReact(1000);
+ cy.contains('New live query').should('not.be.disabled').click();
+ selectAllAgents();
+ getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow} {enter}`);
+ cy.contains('select * from uptime');
+ submitQuery();
+ checkResults();
+ });
+ it('should not be able to add nor edit packs', () => {
+ const PACK_NAME = 'removing-pack';
+
+ navigateTo('/app/osquery/packs');
+ cy.waitForReact(1000);
+ cy.contains('Add pack').should('be.disabled');
+ cy.react('ActiveStateSwitchComponent', {
+ props: { item: { attributes: { name: PACK_NAME } } },
+ })
+ .find('button')
+ .should('be.disabled');
+ cy.contains(PACK_NAME).click();
+ cy.contains(`${PACK_NAME} details`);
+ cy.contains('Edit').should('be.disabled');
+ cy.react('CustomItemAction', {
+ props: { index: 0, item: { id: SAVED_QUERY_ID } },
+ options: { timeout: 3000 },
+ }).should('not.exist');
+ cy.react('CustomItemAction', {
+ props: { index: 1, item: { id: SAVED_QUERY_ID } },
+ options: { timeout: 3000 },
+ }).should('not.exist');
+ });
+ it('should not be able to create new liveQuery from scratch', () => {
+ navigateTo('/app/osquery');
+
+ cy.contains('New live query').click();
+ selectAllAgents();
+ cy.get(LIVE_QUERY_EDITOR).should('not.exist');
+ cy.contains('Submit').should('be.disabled');
+ });
+});
diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts
new file mode 100644
index 0000000000000..805eb134a44f5
--- /dev/null
+++ b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts
@@ -0,0 +1,113 @@
+/*
+ * 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 { login } from '../../tasks/login';
+import { navigateTo } from '../../tasks/navigation';
+import { ROLES } from '../../test';
+import {
+ checkResults,
+ selectAllAgents,
+ submitQuery,
+ inputQuery,
+ typeInECSFieldInput,
+ typeInOsqueryFieldInput,
+} from '../../tasks/live_query';
+import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
+import { getSavedQueriesComplexTest } from '../../tasks/saved_queries';
+
+describe('T2 Analyst - READ + Write Live/Saved + runSavedQueries ', () => {
+ const SAVED_QUERY_ID = 'Saved-Query-Id';
+ const NEW_SAVED_QUERY_ID = 'Saved-Query-Id-T2';
+ const NEW_SAVED_QUERY_DESCRIPTION = 'Test saved query description T2';
+ beforeEach(() => {
+ login(ROLES.t2_analyst);
+ navigateTo('/app/osquery');
+ });
+ before(() => {
+ runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query');
+ });
+
+ after(() => {
+ runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query');
+ });
+
+ getSavedQueriesComplexTest(NEW_SAVED_QUERY_ID, NEW_SAVED_QUERY_DESCRIPTION);
+
+ it('should not be able to add nor edit packs', () => {
+ const PACK_NAME = 'removing-pack';
+
+ navigateTo('/app/osquery/packs');
+ cy.waitForReact(1000);
+ cy.contains('Add pack').should('be.disabled');
+ cy.react('ActiveStateSwitchComponent', {
+ props: { item: { attributes: { name: PACK_NAME } } },
+ })
+ .find('button')
+ .should('be.disabled');
+ cy.contains(PACK_NAME).click();
+ cy.contains(`${PACK_NAME} details`);
+ cy.contains('Edit').should('be.disabled');
+ cy.react('CustomItemAction', {
+ props: { index: 0, item: { id: SAVED_QUERY_ID } },
+ options: { timeout: 3000 },
+ }).should('not.exist');
+ cy.react('CustomItemAction', {
+ props: { index: 1, item: { id: SAVED_QUERY_ID } },
+ options: { timeout: 3000 },
+ }).should('not.exist');
+ });
+
+ it('should run query and enable ecs mapping', () => {
+ const cmd = Cypress.platform === 'darwin' ? '{meta}{enter}' : '{ctrl}{enter}';
+ cy.contains('New live query').click();
+ selectAllAgents();
+ inputQuery('select * from uptime; ');
+ cy.wait(500);
+ // checking submit by clicking cmd+enter
+ inputQuery(cmd);
+ checkResults();
+ cy.contains('View in Discover').should('not.exist');
+ cy.contains('View in Lens').should('not.exist');
+ cy.react('EuiDataGridHeaderCellWrapper', {
+ props: { id: 'osquery.days.number', index: 1 },
+ });
+ cy.react('EuiDataGridHeaderCellWrapper', {
+ props: { id: 'osquery.hours.number', index: 2 },
+ });
+
+ cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click();
+ typeInECSFieldInput('message{downArrow}{enter}');
+ typeInOsqueryFieldInput('days{downArrow}{enter}');
+ submitQuery();
+
+ checkResults();
+ cy.react('EuiDataGridHeaderCellWrapper', {
+ props: { id: 'message', index: 1 },
+ });
+ cy.react('EuiDataGridHeaderCellWrapper', {
+ props: { id: 'osquery.days.number', index: 2 },
+ }).react('EuiIconIndexMapping');
+ });
+ it('to click the edit button and edit pack', () => {
+ navigateTo('/app/osquery/saved_queries');
+
+ cy.react('CustomItemAction', {
+ props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } },
+ }).click();
+ cy.contains('Custom key/value pairs.').should('exist');
+ cy.contains('Hours of uptime').should('exist');
+ cy.react('EuiButtonIcon', { props: { id: 'labels-trash' } }).click();
+ cy.react('EuiButton').contains('Update query').click();
+ cy.wait(5000);
+
+ cy.react('CustomItemAction', {
+ props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } },
+ }).click();
+ cy.contains('Custom key/value pairs').should('not.exist');
+ cy.contains('Hours of uptime').should('not.exist');
+ });
+});
diff --git a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts
deleted file mode 100644
index 11c78560d25fe..0000000000000
--- a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts
+++ /dev/null
@@ -1,30 +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 { login } from '../../tasks/login';
-import { navigateTo } from '../../tasks/navigation';
-import { ROLES } from '../../test';
-import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver';
-
-describe('T1 Analyst - Live Query', () => {
- beforeEach(() => {
- login(ROLES.t1_analyst);
- });
-
- describe('should run a live query', () => {
- before(() => {
- runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query');
- });
- after(() => {
- runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query');
- });
- it('when passed as a saved query ', () => {
- navigateTo('/app/osquery/saved_queries');
- cy.waitForReact(1000);
- });
- });
-});
diff --git a/x-pack/plugins/osquery/cypress/screens/fleet.ts b/x-pack/plugins/osquery/cypress/screens/fleet.ts
index 6be51e5ed24bc..b7cce6484c405 100644
--- a/x-pack/plugins/osquery/cypress/screens/fleet.ts
+++ b/x-pack/plugins/osquery/cypress/screens/fleet.ts
@@ -9,3 +9,4 @@ export const ADD_AGENT_BUTTON = 'addAgentButton';
export const AGENT_POLICIES_TAB = 'fleet-agent-policies-tab';
export const ENROLLMENT_TOKENS_TAB = 'fleet-enrollment-tokens-tab';
+export const DEFAULT_POLICY = 'Default Fleet Server policy';
diff --git a/x-pack/plugins/osquery/cypress/screens/integrations.ts b/x-pack/plugins/osquery/cypress/screens/integrations.ts
index 42c22096cea96..b02efb9cff512 100644
--- a/x-pack/plugins/osquery/cypress/screens/integrations.ts
+++ b/x-pack/plugins/osquery/cypress/screens/integrations.ts
@@ -24,3 +24,6 @@ export const LATEST_VERSION = 'latestVersion';
export const PACKAGE_VERSION = 'packageVersionText';
export const SAVE_PACKAGE_CONFIRM = '[data-test-subj=confirmModalConfirmButton]';
+
+export const getAdvancedButton = () =>
+ cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } });
diff --git a/x-pack/plugins/osquery/cypress/screens/live_query.ts b/x-pack/plugins/osquery/cypress/screens/live_query.ts
index cba4a35c05719..54c19fe508705 100644
--- a/x-pack/plugins/osquery/cypress/screens/live_query.ts
+++ b/x-pack/plugins/osquery/cypress/screens/live_query.ts
@@ -9,4 +9,8 @@ export const AGENT_FIELD = '[data-test-subj="comboBoxInput"]';
export const ALL_AGENTS_OPTION = '[title="All agents"]';
export const LIVE_QUERY_EDITOR = '#osquery_editor';
export const SUBMIT_BUTTON = '#submit-button';
+
export const RESULTS_TABLE_BUTTON = 'dataGridFullScreenButton';
+export const RESULTS_TABLE_CELL_WRRAPER = 'EuiDataGridHeaderCellWrapper';
+export const getSavedQueriesDropdown = () =>
+ cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } });
diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts
similarity index 76%
rename from x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts
rename to x-pack/plugins/osquery/cypress/tasks/saved_queries.ts
index bc8417d5facf5..bfa7b51643382 100644
--- a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts
+++ b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts
@@ -5,8 +5,7 @@
* 2.0.
*/
-import { navigateTo } from '../../tasks/navigation';
-import { RESULTS_TABLE_BUTTON } from '../../screens/live_query';
+import { RESULTS_TABLE_BUTTON } from '../screens/live_query';
import {
checkResults,
BIG_QUERY,
@@ -15,18 +14,9 @@ import {
inputQuery,
selectAllAgents,
submitQuery,
-} from '../../tasks/live_query';
-import { login } from '../../tasks/login';
-
-describe('Super User - Saved queries', () => {
- const SAVED_QUERY_ID = 'Saved-Query-Id';
- const SAVED_QUERY_DESCRIPTION = 'Saved Query Description';
-
- beforeEach(() => {
- login();
- navigateTo('/app/osquery');
- });
+} from './live_query';
+export const getSavedQueriesComplexTest = (savedQueryId: string, savedQueryDescription: string) =>
it(
'should create a new query and verify: \n ' +
'- hidden columns, full screen and sorting \n' +
@@ -78,8 +68,8 @@ describe('Super User - Saved queries', () => {
cy.contains('Exit full screen').should('not.exist');
cy.contains('Save for later').click();
cy.contains('Save query');
- findFormFieldByRowsLabelAndType('ID', SAVED_QUERY_ID);
- findFormFieldByRowsLabelAndType('Description (optional)', SAVED_QUERY_DESCRIPTION);
+ findFormFieldByRowsLabelAndType('ID', savedQueryId);
+ findFormFieldByRowsLabelAndType('Description (optional)', savedQueryDescription);
cy.react('EuiButtonDisplay').contains('Save').click();
// visit Status results
@@ -89,31 +79,30 @@ describe('Super User - Saved queries', () => {
// play saved query
cy.contains('Saved queries').click();
- cy.contains(SAVED_QUERY_ID);
+ cy.contains(savedQueryId);
cy.react('PlayButtonComponent', {
- props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } },
+ props: { savedQuery: { attributes: { id: savedQueryId } } },
}).click();
selectAllAgents();
submitQuery();
// edit saved query
cy.contains('Saved queries').click();
- cy.contains(SAVED_QUERY_ID);
+ cy.contains(savedQueryId);
cy.react('CustomItemAction', {
- props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } },
+ props: { index: 1, item: { attributes: { id: savedQueryId } } },
}).click();
findFormFieldByRowsLabelAndType('Description (optional)', ' Edited');
cy.react('EuiButton').contains('Update query').click();
- cy.contains(`${SAVED_QUERY_DESCRIPTION} Edited`);
+ cy.contains(`${savedQueryDescription} Edited`);
// delete saved query
- cy.contains(SAVED_QUERY_ID);
+ cy.contains(savedQueryId);
cy.react('CustomItemAction', {
- props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } },
+ props: { index: 1, item: { attributes: { id: savedQueryId } } },
}).click();
deleteAndConfirm('query');
- cy.contains(SAVED_QUERY_ID).should('exist');
- cy.contains(SAVED_QUERY_ID).should('not.exist');
+ cy.contains(savedQueryId).should('exist');
+ cy.contains(savedQueryId).should('not.exist');
}
);
-});
diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx
index d92d9ee117fde..2f81394bccde8 100644
--- a/x-pack/plugins/osquery/public/actions/actions_table.tsx
+++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx
@@ -13,7 +13,7 @@ import { useHistory } from 'react-router-dom';
import { useAllActions } from './use_all_actions';
import { Direction } from '../../common/search_strategy';
-import { useRouterNavigate } from '../common/lib/kibana';
+import { useRouterNavigate, useKibana } from '../common/lib/kibana';
interface ActionTableResultsButtonProps {
actionId: string;
@@ -28,6 +28,7 @@ const ActionTableResultsButton: React.FC = ({ act
ActionTableResultsButton.displayName = 'ActionTableResultsButton';
const ActionsTableComponent = () => {
+ const permissions = useKibana().services.application.capabilities.osquery;
const { push } = useHistory();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(20);
@@ -84,6 +85,10 @@ const ActionsTableComponent = () => {
}),
[push]
);
+ const isPlayButtonAvailable = useCallback(
+ () => permissions.runSavedQueries || permissions.writeLiveQueries,
+ [permissions.runSavedQueries, permissions.writeLiveQueries]
+ );
const columns = useMemo(
() => [
@@ -128,6 +133,7 @@ const ActionsTableComponent = () => {
type: 'icon',
icon: 'play',
onClick: handlePlayClick,
+ available: isPlayButtonAvailable,
},
{
render: renderActionsColumn,
@@ -137,6 +143,7 @@ const ActionsTableComponent = () => {
],
[
handlePlayClick,
+ isPlayButtonAvailable,
renderActionsColumn,
renderAgentsColumn,
renderCreatedByColumn,
diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx
index c3770f202c087..0f5caca5d19bd 100644
--- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx
+++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx
@@ -6,12 +6,13 @@
*/
import { EuiLoadingContent } from '@elastic/eui';
-import React, { useEffect } from 'react';
+import React from 'react';
import { PackageCustomExtensionComponentProps } from '../../../fleet/public';
import { NavigationButtons } from './navigation_buttons';
import { DisabledCallout } from './disabled_callout';
-import { useKibana } from '../common/lib/kibana';
+import { MissingPrivileges } from '../routes/components/missing_privileges';
+import { useFetchStatus } from './use_fetch_status';
/**
* Exports Osquery-specific package policy instructions
@@ -19,22 +20,16 @@ import { useKibana } from '../common/lib/kibana';
*/
export const OsqueryManagedCustomButtonExtension = React.memo(
() => {
- const [disabled, setDisabled] = React.useState(null);
- const { http } = useKibana().services;
+ const { loading, disabled, permissionDenied } = useFetchStatus();
- useEffect(() => {
- const fetchStatus = () => {
- http.get<{ install_status: string }>('/internal/osquery/status').then((response) => {
- setDisabled(response?.install_status !== 'installed');
- });
- };
- fetchStatus();
- }, [http]);
-
- if (disabled === null) {
+ if (loading) {
return ;
}
+ if (permissionDenied) {
+ return ;
+ }
+
return (
<>
{disabled ? : null}
diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx
index 1b7b87fe180bf..aaedec1e0dbe1 100644
--- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx
+++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx
@@ -46,6 +46,7 @@ import {
fieldValidators,
ValidationFunc,
} from '../shared_imports';
+import { useFetchStatus } from './use_fetch_status';
// https://github.com/elastic/beats/blob/master/x-pack/osquerybeat/internal/osqd/args.go#L57
const RESTRICTED_CONFIG_OPTIONS = [
@@ -340,6 +341,8 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo<
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const { permissionDenied } = useFetchStatus();
+
return (
<>
{!editMode ? : null}
@@ -366,23 +369,27 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo<
>
) : null}
-
-
-
-
-
-
+ {!permissionDenied && (
+ <>
+
+
+
+
+
+
+ >
+ )}
>
);
});
diff --git a/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx
new file mode 100644
index 0000000000000..3f86675f8be41
--- /dev/null
+++ b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useState, useEffect } from 'react';
+import { useKibana } from '../common/lib/kibana';
+
+export const useFetchStatus = () => {
+ const [loading, setLoading] = useState(true);
+ const [disabled, setDisabled] = useState(false);
+ const [permissionDenied, setPermissionDenied] = useState(false);
+ const { http } = useKibana().services;
+
+ useEffect(() => {
+ const fetchStatus = () => {
+ http
+ .get<{ install_status: string }>('/internal/osquery/status')
+ .then((response) => {
+ setLoading(false);
+ setDisabled(response?.install_status !== 'installed');
+ })
+ .catch((err) => {
+ setLoading(false);
+ if (err.body.statusCode === 403) {
+ setPermissionDenied(true);
+ }
+ });
+ };
+ fetchStatus();
+ }, [http]);
+
+ return { loading, disabled, permissionDenied };
+};
diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx
index bd8e2bf42129f..9164266d6a8c5 100644
--- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx
+++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx
@@ -273,16 +273,26 @@ const LiveQueryFormComponent: React.FC = ({
[permissions.writeSavedQueries]
);
+ const isSavedQueryDisabled = useMemo(
+ () =>
+ queryComponentProps.disabled || !permissions.runSavedQueries || !permissions.readSavedQueries,
+ [permissions.readSavedQueries, permissions.runSavedQueries, queryComponentProps.disabled]
+ );
+
const queryFieldStepContent = useMemo(
() => (
<>
{queryField ? (
<>
-
-
+ {!isSavedQueryDisabled && (
+ <>
+
+
+ >
+ )}
= ({
[
queryField,
queryComponentProps,
- permissions.runSavedQueries,
permissions.writeSavedQueries,
handleSavedQueryChange,
ecsMappingField,
@@ -372,6 +381,7 @@ const LiveQueryFormComponent: React.FC = ({
enabled,
isSubmitting,
submit,
+ isSavedQueryDisabled,
]
);
diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx
index 836350d12d43e..c99662804b1e2 100644
--- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx
+++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx
@@ -207,6 +207,7 @@ const ViewResultsInLensActionComponent: React.FC {
const lensService = useKibana().services.lens;
+ const isLensAvailable = lensService?.canUseEditor();
const handleClick = useCallback(
(event) => {
@@ -230,14 +231,12 @@ const ViewResultsInLensActionComponent: React.FC
+
{VIEW_IN_LENS}
);
@@ -247,7 +246,7 @@ const ViewResultsInLensActionComponent: React.FC
@@ -264,7 +263,10 @@ const ViewResultsInDiscoverActionComponent: React.FC {
- const locator = useKibana().services.discover?.locator;
+ const { discover, application } = useKibana().services;
+ const locator = discover?.locator;
+ const discoverPermissions = application.capabilities.discover;
+
const [discoverUrl, setDiscoverUrl] = useState('');
useEffect(() => {
@@ -336,6 +338,9 @@ const ViewResultsInDiscoverActionComponent: React.FC
diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx
index f16e32a62cb4f..d019b831d96f5 100644
--- a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx
+++ b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx
@@ -125,12 +125,12 @@ const SavedQueriesPageComponent = () => {
);
const renderPlayAction = useCallback(
- (item: SavedQuerySO) => (
-
- ),
+ (item: SavedQuerySO) =>
+ permissions.runSavedQueries || permissions.writeLiveQueries ? (
+
+ ) : (
+ <>>
+ ),
[permissions.runSavedQueries, permissions.writeLiveQueries]
);
diff --git a/x-pack/plugins/osquery/scripts/roles_users/README.md b/x-pack/plugins/osquery/scripts/roles_users/README.md
index d0a28049c865b..aadc696a5f504 100644
--- a/x-pack/plugins/osquery/scripts/roles_users/README.md
+++ b/x-pack/plugins/osquery/scripts/roles_users/README.md
@@ -4,7 +4,7 @@ Initial version of roles support for Osquery
|:--------------------------------------------:|:-----------------------------------------------:|:-------------------------------:|:-------------:|:-----:|:-------------------:|:-----:|:-------------:|:----------:|
| NO Data Source access user | none | none | none | none | none | none | none | none |
| Reader (read-only user) | read | read | read | read | none | none | none | none |
-| T1 Analyst | read | read, write (run saved queries) | read | read | none | none | none | none |
+| T1 Analyst | read | read, (run saved queries) | read | read | none | none | none | none |
| T2 Analyst | read | read, write (tbc) | all | read | none | read | none | none |
| Hunter / T3 Analyst | read | all | all | all | none | all | read | all |
| SOC Manager | read | all | all | all | none | all | read | all |
diff --git a/x-pack/plugins/osquery/scripts/roles_users/index.ts b/x-pack/plugins/osquery/scripts/roles_users/index.ts
index 1f51d8691a715..ce29ba92e2590 100644
--- a/x-pack/plugins/osquery/scripts/roles_users/index.ts
+++ b/x-pack/plugins/osquery/scripts/roles_users/index.ts
@@ -5,4 +5,6 @@
* 2.0.
*/
+export * from './reader';
export * from './t1_analyst';
+export * from './t2_analyst';
diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh
new file mode 100755
index 0000000000000..57704f7abf0d3
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh
@@ -0,0 +1,11 @@
+
+#
+# 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.
+#
+
+curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+-XDELETE ${ELASTICSEARCH_URL}/_security/user/reader
diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh
new file mode 100755
index 0000000000000..37db6e10ced55
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh
@@ -0,0 +1,11 @@
+
+#
+# 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.
+#
+
+curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+-XGET ${KIBANA_URL}/api/security/role/reader | jq -S .
diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts
similarity index 61%
rename from x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx
rename to x-pack/plugins/osquery/scripts/roles_users/reader/index.ts
index 550cefcf13e92..6fbd33c69b3a6 100644
--- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx
+++ b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts
@@ -5,8 +5,7 @@
* 2.0.
*/
-export * from './dns';
-export * from './network_events';
-export * from './tls_handshakes';
-export * from './unique_flows';
-export * from './unique_private_ips';
+import * as readerUser from './user.json';
+import * as readerRole from './role.json';
+
+export { readerUser, readerRole };
diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh
new file mode 100755
index 0000000000000..338783465f993
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh
@@ -0,0 +1,14 @@
+
+#
+# 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.
+#
+
+ROLE_CONFIG=(${@:-./detections_role.json})
+
+curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+-XPUT ${KIBANA_URL}/api/security/role/reader \
+-d @${ROLE_CONFIG}
diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh
new file mode 100755
index 0000000000000..8a93326a820b7
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh
@@ -0,0 +1,14 @@
+
+#
+# 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.
+#
+
+USER=(${@:-./detections_user.json})
+
+curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ ${ELASTICSEARCH_URL}/_security/user/reader \
+-d @${USER}
diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/role.json b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json
new file mode 100644
index 0000000000000..85c2ff52f84d6
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json
@@ -0,0 +1,19 @@
+{
+ "elasticsearch": {
+ "indices": [
+ {
+ "names": ["logs-osquery_manager*"],
+ "privileges": ["read"]
+ }
+ ]
+ },
+ "kibana": [
+ {
+ "feature": {
+ "osquery": ["read"]
+ },
+ "spaces": ["*"]
+ }
+ ]
+}
+
diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/user.json b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json
new file mode 100644
index 0000000000000..a6c3c38cdd16e
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json
@@ -0,0 +1,6 @@
+{
+ "password": "changeme",
+ "roles": ["reader"],
+ "full_name": "Reader",
+ "email": "osquery@example.com"
+}
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json
index 85c2ff52f84d6..12d5c2607f9ab 100644
--- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json
+++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json
@@ -10,7 +10,7 @@
"kibana": [
{
"feature": {
- "osquery": ["read"]
+ "osquery": ["read", "run_saved_queries" ]
},
"spaces": ["*"]
}
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json
index 203abec8ad433..cef1935d57068 100644
--- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json
+++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json
@@ -2,5 +2,5 @@
"password": "changeme",
"roles": ["t1_analyst"],
"full_name": "T1 Analyst",
- "email": "detections-reader@example.com"
+ "email": "osquery@example.com"
}
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh
new file mode 100755
index 0000000000000..6dccb0d8c6067
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh
@@ -0,0 +1,11 @@
+
+#
+# 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.
+#
+
+curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+-XDELETE ${ELASTICSEARCH_URL}/_security/user/t2_analyst
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh
new file mode 100755
index 0000000000000..ce9149d8b9fc7
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh
@@ -0,0 +1,11 @@
+
+#
+# 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.
+#
+
+curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+-XGET ${KIBANA_URL}/api/security/role/t2_analyst | jq -S .
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts
new file mode 100644
index 0000000000000..a3a8357e67c7f
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts
@@ -0,0 +1,11 @@
+/*
+ * 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 * as t2AnalystUser from './user.json';
+import * as t2AnalystRole from './role.json';
+
+export { t2AnalystUser, t2AnalystRole };
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh
new file mode 100755
index 0000000000000..b94c738c3e3db
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh
@@ -0,0 +1,14 @@
+
+#
+# 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.
+#
+
+ROLE_CONFIG=(${@:-./detections_role.json})
+
+curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+-XPUT ${KIBANA_URL}/api/security/role/t2_analyst \
+-d @${ROLE_CONFIG}
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh
new file mode 100755
index 0000000000000..3a901490515af
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh
@@ -0,0 +1,14 @@
+
+#
+# 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.
+#
+
+USER=(${@:-./detections_user.json})
+
+curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
+ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
+ ${ELASTICSEARCH_URL}/_security/user/t2_analyst \
+-d @${USER}
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json
new file mode 100644
index 0000000000000..43133a62ec56b
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json
@@ -0,0 +1,19 @@
+{
+ "elasticsearch": {
+ "indices": [
+ {
+ "names": ["logs-osquery_manager*"],
+ "privileges": ["read"]
+ }
+ ]
+ },
+ "kibana": [
+ {
+ "feature": {
+ "osquery": ["read", "live_queries_all", "saved_queries_all", "packs_read", "run_saved_queries"]
+ },
+ "spaces": ["*"]
+ }
+ ]
+}
+
diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json
new file mode 100644
index 0000000000000..36096b2cc8f06
--- /dev/null
+++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json
@@ -0,0 +1,6 @@
+{
+ "password": "changeme",
+ "roles": ["t2_analyst"],
+ "full_name": "T2 Analyst",
+ "email": "osquery@example.com"
+}
diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts
index 37c08d712e3f6..b37e6032331dd 100644
--- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts
+++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts
@@ -9,7 +9,6 @@ import { pickBy, isEmpty } from 'lodash';
import uuid from 'uuid';
import moment from 'moment-timezone';
-import { PLUGIN_ID } from '../../../common';
import { IRouter } from '../../../../../../src/core/server';
import { OsqueryAppContext } from '../../lib/osquery_app_context_services';
@@ -22,6 +21,7 @@ import {
import { incrementCount } from '../usage';
import { getInternalSavedObjectsClient } from '../../usage/collector';
+import { savedQuerySavedObjectType } from '../../../common/types';
export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => {
router.post(
@@ -33,15 +33,38 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon
CreateActionRequestBodySchema
>(createActionRequestBodySchema),
},
- options: {
- tags: [`access:${PLUGIN_ID}-readLiveQueries`, `access:${PLUGIN_ID}-runSavedQueries`],
- },
},
async (context, request, response) => {
const esClient = context.core.elasticsearch.client.asInternalUser;
+ const soClient = context.core.savedObjects.client;
const internalSavedObjectsClient = await getInternalSavedObjectsClient(
osqueryContext.getStartServices
);
+ const [coreStartServices] = await osqueryContext.getStartServices();
+ let savedQueryId = request.body.saved_query_id;
+
+ const {
+ osquery: { writeLiveQueries, runSavedQueries },
+ } = await coreStartServices.capabilities.resolveCapabilities(request);
+
+ const isInvalid = !(writeLiveQueries || (runSavedQueries && request.body.saved_query_id));
+
+ if (isInvalid) {
+ return response.forbidden();
+ }
+
+ if (request.body.saved_query_id && runSavedQueries) {
+ const savedQueries = await soClient.find({
+ type: savedQuerySavedObjectType,
+ });
+ const actualSavedQuery = savedQueries.saved_objects.find(
+ (savedQuery) => savedQuery.id === request.body.saved_query_id
+ );
+
+ if (actualSavedQuery) {
+ savedQueryId = actualSavedQuery.id;
+ }
+ }
const { agentSelection } = request.body as { agentSelection: AgentSelection };
const selectedAgents = await parseAgentSelection(
@@ -55,8 +78,6 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon
return response.badRequest({ body: new Error('No agents found for selection') });
}
- // TODO: Add check for `runSavedQueries` only
-
try {
const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username;
const action = {
@@ -71,7 +92,7 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon
{
id: uuid.v4(),
query: request.body.query,
- saved_query_id: request.body.saved_query_id,
+ saved_query_id: savedQueryId,
ecs_mapping: request.body.ecs_mapping,
},
(value) => !isEmpty(value)
diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts
new file mode 100644
index 0000000000000..7dc0f51f15f08
--- /dev/null
+++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { getIsKibanaRequest } from './get_is_kibana_request';
+
+describe('getIsKibanaRequest', () => {
+ it('should ensure the request has a kbn version and referer', () => {
+ expect(
+ getIsKibanaRequest({
+ 'kbn-version': 'foo',
+ referer: 'somwhere',
+ })
+ ).toBe(true);
+ });
+
+ it('should return false if the kbn version is missing', () => {
+ expect(
+ getIsKibanaRequest({
+ referer: 'somwhere',
+ })
+ ).toBe(false);
+ });
+
+ it('should return false if the referer is missing', () => {
+ expect(
+ getIsKibanaRequest({
+ 'kbn-version': 'foo',
+ })
+ ).toBe(false);
+ });
+});
diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts
new file mode 100644
index 0000000000000..c0961b84c7c28
--- /dev/null
+++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import type { Headers } from 'kibana/server';
+
+/**
+ * Taken from
+ * https://github.com/elastic/kibana/blob/ec30f2aeeb10fb64b507935e558832d3ef5abfaa/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts#L113-L118
+ */
+export const getIsKibanaRequest = (headers?: Headers): boolean => {
+ // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client.
+ // We can't be 100% certain, but this is a reasonable attempt.
+ return !!(headers && headers['kbn-version'] && headers.referer);
+};
diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts
index 292e987879d58..df32abcc80865 100644
--- a/x-pack/plugins/rule_registry/server/plugin.ts
+++ b/x-pack/plugins/rule_registry/server/plugin.ts
@@ -29,7 +29,7 @@ import { AlertsClientFactory } from './alert_data_client/alerts_client_factory';
import { AlertsClient } from './alert_data_client/alerts_client';
import { RacApiRequestHandlerContext, RacRequestHandlerContext } from './types';
import { defineRoutes } from './routes';
-import { ruleRegistrySearchStrategyProvider } from './search_strategy';
+import { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy';
export interface RuleRegistryPluginSetupDependencies {
security?: SecurityPluginSetup;
@@ -115,7 +115,7 @@ export class RuleRegistryPlugin
);
plugins.data.search.registerSearchStrategy(
- 'ruleRegistryAlertsSearchStrategy',
+ RULE_SEARCH_STRATEGY_NAME,
ruleRegistrySearchStrategy
);
});
diff --git a/x-pack/plugins/rule_registry/server/search_strategy/index.ts b/x-pack/plugins/rule_registry/server/search_strategy/index.ts
index 63f39430a5522..d6364983f2d26 100644
--- a/x-pack/plugins/rule_registry/server/search_strategy/index.ts
+++ b/x-pack/plugins/rule_registry/server/search_strategy/index.ts
@@ -5,4 +5,4 @@
* 2.0.
*/
-export { ruleRegistrySearchStrategyProvider } from './search_strategy';
+export { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy';
diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts
index 2ea4b4c191c0d..f5f7d8d164b48 100644
--- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts
+++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts
@@ -8,7 +8,11 @@ import { of } from 'rxjs';
import { merge } from 'lodash';
import { loggerMock } from '@kbn/logging-mocks';
import { AlertConsumers } from '@kbn/rule-data-utils';
-import { ruleRegistrySearchStrategyProvider, EMPTY_RESPONSE } from './search_strategy';
+import {
+ ruleRegistrySearchStrategyProvider,
+ EMPTY_RESPONSE,
+ RULE_SEARCH_STRATEGY_NAME,
+} from './search_strategy';
import { ruleDataServiceMock } from '../rule_data_plugin_service/rule_data_plugin_service.mock';
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
import { SearchStrategyDependencies } from '../../../../../src/plugins/data/server';
@@ -18,6 +22,9 @@ import { spacesMock } from '../../../spaces/server/mocks';
import { RuleRegistrySearchRequest } from '../../common/search_strategy';
import { IndexInfo } from '../rule_data_plugin_service/index_info';
import * as getAuthzFilterImport from '../lib/get_authz_filter';
+import { getIsKibanaRequest } from '../lib/get_is_kibana_request';
+
+jest.mock('../lib/get_is_kibana_request');
const getBasicResponse = (overwrites = {}) => {
return merge(
@@ -89,6 +96,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
return of(response);
});
+ (getIsKibanaRequest as jest.Mock).mockImplementation(() => {
+ return true;
+ });
+
getAuthzFilterSpy = jest
.spyOn(getAuthzFilterImport, 'getAuthzFilter')
.mockImplementation(async () => {
@@ -377,4 +388,46 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
(data.search.searchAsInternalUser.search as jest.Mock).mock.calls[0][0].params.body.sort
).toStrictEqual([{ test: { order: 'desc' } }]);
});
+
+ it('should reject, to the best of our ability, public requests', async () => {
+ (getIsKibanaRequest as jest.Mock).mockImplementation(() => {
+ return false;
+ });
+ const request: RuleRegistrySearchRequest = {
+ featureIds: [AlertConsumers.LOGS],
+ sort: [
+ {
+ test: {
+ order: 'desc',
+ },
+ },
+ ],
+ };
+ const options = {};
+ const deps = {
+ request: {},
+ };
+
+ const strategy = ruleRegistrySearchStrategyProvider(
+ data,
+ ruleDataService,
+ alerting,
+ logger,
+ security,
+ spaces
+ );
+
+ let err = null;
+ try {
+ await strategy
+ .search(request, options, deps as unknown as SearchStrategyDependencies)
+ .toPromise();
+ } catch (e) {
+ err = e;
+ }
+ expect(err).not.toBeNull();
+ expect(err.message).toBe(
+ `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.`
+ );
+ });
});
diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts
index 8cd0a0d410c9b..da32d68a85f86 100644
--- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts
+++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
import { map, mergeMap, catchError } from 'rxjs/operators';
+import Boom from '@hapi/boom';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { Logger } from 'src/core/server';
import { from, of } from 'rxjs';
@@ -23,11 +24,14 @@ import { Dataset } from '../rule_data_plugin_service/index_options';
import { MAX_ALERT_SEARCH_SIZE } from '../../common/constants';
import { AlertAuditAction, alertAuditEvent } from '../';
import { getSpacesFilter, getAuthzFilter } from '../lib';
+import { getIsKibanaRequest } from '../lib/get_is_kibana_request';
export const EMPTY_RESPONSE: RuleRegistrySearchResponse = {
rawResponse: {} as RuleRegistrySearchResponse['rawResponse'],
};
+export const RULE_SEARCH_STRATEGY_NAME = 'privateRuleRegistryAlertsSearchStrategy';
+
export const ruleRegistrySearchStrategyProvider = (
data: PluginStart,
ruleDataService: IRuleDataService,
@@ -40,6 +44,13 @@ export const ruleRegistrySearchStrategyProvider = (
const requestUserEs = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY);
return {
search: (request, options, deps) => {
+ // We want to ensure this request came from our UI. We can't really do this
+ // but we have a best effort we can try
+ if (!getIsKibanaRequest(deps.request.headers)) {
+ throw Boom.notFound(
+ `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.`
+ );
+ }
// SIEM uses RBAC fields in their alerts but also utilizes ES DLS which
// is different than every other solution so we need to special case
// those requests.
@@ -48,7 +59,7 @@ export const ruleRegistrySearchStrategyProvider = (
siemRequest = true;
} else if (request.featureIds.includes(AlertConsumers.SIEM)) {
throw new Error(
- 'The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.'
+ `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.`
);
}
@@ -74,7 +85,7 @@ export const ruleRegistrySearchStrategyProvider = (
const indices: string[] = request.featureIds.reduce((accum: string[], featureId) => {
if (!isValidFeatureId(featureId)) {
logger.warn(
- `Found invalid feature '${featureId}' while using rule registry search strategy. No alert data from this feature will be searched.`
+ `Found invalid feature '${featureId}' while using ${RULE_SEARCH_STRATEGY_NAME} search strategy. No alert data from this feature will be searched.`
);
return accum;
}
diff --git a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts
index c27ca90e6e2f2..7f5d31b781310 100644
--- a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts
+++ b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts
@@ -21,6 +21,16 @@ interface ReturnValue {
error?: string;
}
+interface ProfileResponse {
+ profile?: { shards: ShardSerialized[] };
+ _shards: {
+ failed: number;
+ skipped: number;
+ total: number;
+ successful: number;
+ };
+}
+
const extractProfilerErrorMessage = (e: any): string | undefined => {
if (e.body?.attributes?.error?.reason) {
const { reason, line, col } = e.body.attributes.error;
@@ -67,8 +77,7 @@ export const useRequestProfile = () => {
try {
const resp = await http.post<
- | { ok: true; resp: { profile: { shards: ShardSerialized[] } } }
- | { ok: false; err: { msg: string } }
+ { ok: true; resp: ProfileResponse } | { ok: false; err: { msg: string } }
>('../api/searchprofiler/profile', {
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
@@ -78,7 +87,23 @@ export const useRequestProfile = () => {
return { data: null, error: resp.err.msg };
}
- return { data: resp.resp.profile.shards };
+ // If a user attempts to run Search Profiler without any indices,
+ // _shards=0 and a "profile" output will not be returned
+ if (resp.resp._shards.total === 0) {
+ notifications.addDanger({
+ 'data-test-subj': 'noShardsNotification',
+ title: i18n.translate('xpack.searchProfiler.errorNoShardsTitle', {
+ defaultMessage: 'Unable to profile',
+ }),
+ text: i18n.translate('xpack.searchProfiler.errorNoShardsDescription', {
+ defaultMessage: 'Verify your index input matches a valid index',
+ }),
+ });
+
+ return { data: null };
+ }
+
+ return { data: resp.resp.profile!.shards };
} catch (e) {
const profilerErrorMessage = extractProfilerErrorMessage(e);
if (profilerErrorMessage) {
diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts
index 2fd412eb357b6..cc64b7e640f1f 100644
--- a/x-pack/plugins/security_solution/common/constants.ts
+++ b/x-pack/plugins/security_solution/common/constants.ts
@@ -92,36 +92,38 @@ export enum SecurityPageName {
detectionAndResponse = 'detection_response',
endpoints = 'endpoints',
eventFilters = 'event_filters',
- hostIsolationExceptions = 'host_isolation_exceptions',
events = 'events',
exceptions = 'exceptions',
explore = 'explore',
+ hostIsolationExceptions = 'host_isolation_exceptions',
hosts = 'hosts',
hostsAnomalies = 'hosts-anomalies',
hostsExternalAlerts = 'hosts-external_alerts',
hostsRisk = 'hosts-risk',
- users = 'users',
- usersAnomalies = 'users-anomalies',
- usersRisk = 'users-risk',
investigate = 'investigate',
+ landing = 'get_started',
network = 'network',
networkAnomalies = 'network-anomalies',
networkDns = 'network-dns',
networkExternalAlerts = 'network-external_alerts',
networkHttp = 'network-http',
networkTls = 'network-tls',
- timelines = 'timelines',
- timelinesTemplates = 'timelines-templates',
overview = 'overview',
policies = 'policies',
rules = 'rules',
+ timelines = 'timelines',
+ timelinesTemplates = 'timelines-templates',
trustedApps = 'trusted_apps',
uncommonProcesses = 'uncommon_processes',
+ users = 'users',
+ usersAnomalies = 'users-anomalies',
+ usersRisk = 'users-risk',
}
export const TIMELINES_PATH = '/timelines' as const;
export const CASES_PATH = '/cases' as const;
export const OVERVIEW_PATH = '/overview' as const;
+export const LANDING_PATH = '/get_started' as const;
export const DETECTION_RESPONSE_PATH = '/detection_response' as const;
export const DETECTIONS_PATH = '/detections' as const;
export const ALERTS_PATH = '/alerts' as const;
@@ -140,6 +142,7 @@ export const HOST_ISOLATION_EXCEPTIONS_PATH =
export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const;
export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const;
+export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const;
export const APP_DETECTION_RESPONSE_PATH = `${APP_PATH}${DETECTION_RESPONSE_PATH}` as const;
export const APP_MANAGEMENT_PATH = `${APP_PATH}${MANAGEMENT_PATH}` as const;
diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts
index e6f2669c95c34..737d81cc9d1ed 100644
--- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts
@@ -256,19 +256,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator = {
- [K in ConditionEntryField]?: T;
+ [K in AllConditionEntryFields]?: T;
};
-export interface ConditionEntry<
- F extends ConditionEntryField = ConditionEntryField,
- T extends EntryTypes = EntryTypes
-> {
- field: F;
+export interface ConditionEntry {
+ field: AllConditionEntryFields;
type: T;
operator: 'included';
value: string | string[];
diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts
index ab60d87973983..5e933efbbc61d 100644
--- a/x-pack/plugins/security_solution/common/types/timeline/index.ts
+++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts
@@ -314,6 +314,8 @@ export type TimelineWithoutExternalRefs = Omit {
+ before(() => {
+ cleanKibana();
+ loginAndWaitForPage(USERS_URL);
+ });
+
+ it(`renders events tab`, () => {
+ cy.get(EVENTS_TAB).click();
+
+ cy.get(EVENTS_TAB_CONTENT).should('exist');
+ });
+});
diff --git a/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts
new file mode 100644
index 0000000000000..a2b62bc892032
--- /dev/null
+++ b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.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 {
+ EXTERNAL_ALERTS_TAB,
+ EXTERNAL_ALERTS_TAB_CONTENT,
+} from '../../screens/users/user_external_alerts';
+import { cleanKibana } from '../../tasks/common';
+
+import { loginAndWaitForPage } from '../../tasks/login';
+
+import { USERS_URL } from '../../urls/navigation';
+
+describe('Users external alerts tab', () => {
+ before(() => {
+ cleanKibana();
+ loginAndWaitForPage(USERS_URL);
+ });
+
+ it(`renders external alerts tab`, () => {
+ cy.get(EXTERNAL_ALERTS_TAB).click();
+
+ cy.get(EXTERNAL_ALERTS_TAB_CONTENT).should('exist');
+ });
+});
diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts
index e478f16e72844..42f16340e6ac6 100644
--- a/x-pack/plugins/security_solution/cypress/screens/overview.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts
@@ -144,7 +144,7 @@ export const OVERVIEW_HOST_STATS = '[data-test-subj="overview-hosts-stats"]';
export const OVERVIEW_NETWORK_STATS = '[data-test-subj="overview-network-stats"]';
-export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="empty-page"]';
+export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="siem-landing-page"]';
export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timelines"]';
diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts
new file mode 100644
index 0000000000000..c2bcd30f9d1c2
--- /dev/null
+++ b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts
@@ -0,0 +1,9 @@
+/*
+ * 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 const EVENTS_TAB = '[data-test-subj="navigation-events"]';
+export const EVENTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]';
diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts
new file mode 100644
index 0000000000000..bc98b3bc59f37
--- /dev/null
+++ b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts
@@ -0,0 +1,9 @@
+/*
+ * 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 const EXTERNAL_ALERTS_TAB = '[data-test-subj="navigation-externalAlerts"]';
+export const EXTERNAL_ALERTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]';
diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts
index 144095d0aa528..efb220467c9d0 100644
--- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts
+++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts
@@ -32,9 +32,11 @@ import {
TRUSTED_APPLICATIONS,
POLICIES,
ENDPOINTS,
+ GETTING_STARTED,
} from '../translations';
import {
OVERVIEW_PATH,
+ LANDING_PATH,
DETECTION_RESPONSE_PATH,
ALERTS_PATH,
RULES_PATH,
@@ -84,6 +86,18 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
],
order: 9000,
},
+ {
+ id: SecurityPageName.landing,
+ title: GETTING_STARTED,
+ path: LANDING_PATH,
+ navLinkStatus: AppNavLinkStatus.visible,
+ features: [FEATURE.general],
+ keywords: [
+ i18n.translate('xpack.securitySolution.search.getStarted', {
+ defaultMessage: 'Getting started',
+ }),
+ ],
+ },
{
id: SecurityPageName.detectionAndResponse,
title: DETECTION_RESPONSE,
diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts
index 0b06d02d46464..1ae5544dbd740 100644
--- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts
+++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts
@@ -30,6 +30,7 @@ import {
SecurityPageName,
APP_HOST_ISOLATION_EXCEPTIONS_PATH,
APP_USERS_PATH,
+ APP_LANDING_PATH,
} from '../../../common/constants';
export const navTabs: SecurityNav = {
@@ -40,6 +41,13 @@ export const navTabs: SecurityNav = {
disabled: false,
urlKey: 'overview',
},
+ [SecurityPageName.landing]: {
+ id: SecurityPageName.landing,
+ name: i18n.GETTING_STARTED,
+ href: APP_LANDING_PATH,
+ disabled: false,
+ urlKey: 'get_started',
+ },
[SecurityPageName.detectionAndResponse]: {
id: SecurityPageName.detectionAndResponse,
name: i18n.DETECTION_RESPONSE,
diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts
index 2e0743de69043..f0ebb711f1f38 100644
--- a/x-pack/plugins/security_solution/public/app/translations.ts
+++ b/x-pack/plugins/security_solution/public/app/translations.ts
@@ -22,6 +22,10 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', {
defaultMessage: 'Hosts',
});
+export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', {
+ defaultMessage: 'Getting started',
+});
+
export const NETWORK = i18n.translate('xpack.securitySolution.navigation.network', {
defaultMessage: 'Network',
});
diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx
new file mode 100644
index 0000000000000..7abca14a2e55f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx
@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+import { TimelineId } from '../../../../common/types';
+import { HostsType } from '../../../hosts/store/model';
+import { TestProviders } from '../../mock';
+import { EventsQueryTabBody, EventsQueryTabBodyComponentProps } from './events_query_tab_body';
+import { useGlobalFullScreen } from '../../containers/use_full_screen';
+import * as tGridActions from '../../../../../timelines/public/store/t_grid/actions';
+
+jest.mock('../../lib/kibana', () => {
+ const original = jest.requireActual('../../lib/kibana');
+
+ return {
+ ...original,
+ useKibana: () => ({
+ services: {
+ ...original.useKibana().services,
+ cases: {
+ ui: {
+ getCasesContext: jest.fn(),
+ },
+ },
+ },
+ }),
+ };
+});
+
+const FakeStatefulEventsViewer = () => {'MockedStatefulEventsViewer'}
;
+jest.mock('../events_viewer', () => ({ StatefulEventsViewer: FakeStatefulEventsViewer }));
+
+jest.mock('../../containers/use_full_screen', () => ({
+ useGlobalFullScreen: jest.fn().mockReturnValue({
+ globalFullScreen: true,
+ }),
+}));
+
+describe('EventsQueryTabBody', () => {
+ const commonProps: EventsQueryTabBodyComponentProps = {
+ indexNames: ['test-index'],
+ setQuery: jest.fn(),
+ timelineId: TimelineId.test,
+ type: HostsType.page,
+ endDate: new Date('2000').toISOString(),
+ startDate: new Date('2000').toISOString(),
+ };
+
+ it('renders EventsViewer', () => {
+ const { queryByText } = render(
+
+
+
+ );
+
+ expect(queryByText('MockedStatefulEventsViewer')).toBeInTheDocument();
+ });
+
+ it('renders the matrix histogram when globalFullScreen is false', () => {
+ (useGlobalFullScreen as jest.Mock).mockReturnValue({
+ globalFullScreen: false,
+ });
+
+ const { queryByTestId } = render(
+
+
+
+ );
+
+ expect(queryByTestId('eventsHistogramQueryPanel')).toBeInTheDocument();
+ });
+
+ it("doesn't render the matrix histogram when globalFullScreen is true", () => {
+ (useGlobalFullScreen as jest.Mock).mockReturnValue({
+ globalFullScreen: true,
+ });
+
+ const { queryByTestId } = render(
+
+
+
+ );
+
+ expect(queryByTestId('eventsHistogramQueryPanel')).not.toBeInTheDocument();
+ });
+
+ it('deletes query when unmouting', () => {
+ const mockDeleteQuery = jest.fn();
+ const { unmount } = render(
+
+
+
+ );
+ unmount();
+
+ expect(mockDeleteQuery).toHaveBeenCalled();
+ });
+
+ it('initializes t-grid', () => {
+ const spy = jest.spyOn(tGridActions, 'initializeTGridSettings');
+ render(
+
+
+
+ );
+
+ expect(spy).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx
similarity index 72%
rename from x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx
rename to x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx
index 59c3322fb02ed..cfd6546470d4a 100644
--- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx
@@ -8,27 +8,28 @@
import React, { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
+import { Filter } from '@kbn/es-query';
import { TimelineId } from '../../../../common/types/timeline';
-import { StatefulEventsViewer } from '../../../common/components/events_viewer';
+import { StatefulEventsViewer } from '../events_viewer';
import { timelineActions } from '../../../timelines/store/timeline';
-import { HostsComponentsQueryProps } from './types';
-import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model';
-import {
- MatrixHistogramOption,
- MatrixHistogramConfigs,
-} from '../../../common/components/matrix_histogram/types';
-import { MatrixHistogram } from '../../../common/components/matrix_histogram';
-import { useGlobalFullScreen } from '../../../common/containers/use_full_screen';
-import * as i18n from '../translations';
+import { eventsDefaultModel } from '../events_viewer/default_model';
+
+import { MatrixHistogram } from '../matrix_histogram';
+import { useGlobalFullScreen } from '../../containers/use_full_screen';
+import * as i18n from '../../../hosts/pages/translations';
import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution';
import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns';
import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers';
import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer';
-import { SourcererScopeName } from '../../../common/store/sourcerer/model';
-import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
+import { SourcererScopeName } from '../../store/sourcerer/model';
+import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants';
-import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions';
import { getEventsHistogramLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/events';
+import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions';
+import { GlobalTimeArgs } from '../../containers/use_global_time';
+import { MatrixHistogramConfigs, MatrixHistogramOption } from '../matrix_histogram/types';
+import { QueryTabBodyProps as UserQueryTabBodyProps } from '../../../users/pages/navigation/types';
+import { QueryTabBodyProps as HostQueryTabBodyProps } from '../../../hosts/pages/navigation/types';
const EVENTS_HISTOGRAM_ID = 'eventsHistogramQuery';
@@ -61,7 +62,17 @@ export const histogramConfigs: MatrixHistogramConfigs = {
getLensAttributes: getEventsHistogramLensAttributes,
};
-const EventsQueryTabBodyComponent: React.FC = ({
+type QueryTabBodyProps = UserQueryTabBodyProps | HostQueryTabBodyProps;
+
+export type EventsQueryTabBodyComponentProps = QueryTabBodyProps & {
+ deleteQuery?: GlobalTimeArgs['deleteQuery'];
+ indexNames: string[];
+ pageFilters?: Filter[];
+ setQuery: GlobalTimeArgs['setQuery'];
+ timelineId: TimelineId;
+};
+
+const EventsQueryTabBodyComponent: React.FC = ({
deleteQuery,
endDate,
filterQuery,
@@ -69,6 +80,7 @@ const EventsQueryTabBodyComponent: React.FC = ({
pageFilters,
setQuery,
startDate,
+ timelineId,
}) => {
const dispatch = useDispatch();
const { globalFullScreen } = useGlobalFullScreen();
@@ -78,7 +90,7 @@ const EventsQueryTabBodyComponent: React.FC = ({
useEffect(() => {
dispatch(
timelineActions.initializeTGridSettings({
- id: TimelineId.hostsPageEvents,
+ id: timelineId,
defaultColumns: eventsDefaultModel.columns.map((c) =>
!tGridEnabled && c.initialWidth == null
? {
@@ -89,7 +101,7 @@ const EventsQueryTabBodyComponent: React.FC = ({
),
})
);
- }, [dispatch, tGridEnabled]);
+ }, [dispatch, tGridEnabled, timelineId]);
useEffect(() => {
return () => {
@@ -119,7 +131,7 @@ const EventsQueryTabBodyComponent: React.FC = ({
defaultModel={eventsDefaultModel}
end={endDate}
entityType="events"
- id={TimelineId.hostsPageEvents}
+ id={timelineId}
leadingControlColumns={leadingControlColumns}
pageFilters={pageFilters}
renderCellValue={DefaultCellRenderer}
diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap
index 6701224289e66..45a6e20cf087d 100644
--- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap
@@ -18,19 +18,25 @@ exports[`HeaderSection it renders 1`] = `
responsive={false}
>
-
-
-
+
- Test title
-
-
-
+
+
+ Test title
+
+
+
+
+
diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx
index 5ec97ea59bc1d..2296dc78241f2 100644
--- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx
@@ -180,4 +180,94 @@ describe('HeaderSection', () => {
expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false);
});
+
+ test('it does not render query-toggle-header when no arguments provided', () => {
+ const wrapper = mount(
+
+
+ {'Test children'}
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(false);
+ });
+
+ test('it does render query-toggle-header when toggleQuery arguments provided', () => {
+ const wrapper = mount(
+
+
+ {'Test children'}
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(true);
+ });
+
+ test('it does render everything but title when toggleStatus = true', () => {
+ const wrapper = mount(
+
+
+ {'Test children'}
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe(
+ 'arrowDown'
+ );
+ expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe(
+ true
+ );
+ expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true);
+ expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(true);
+ expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true);
+ });
+ test('it does not render anything but title when toggleStatus = false', () => {
+ const wrapper = mount(
+
+
+ {'Test children'}
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe(
+ 'arrowRight'
+ );
+ expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe(
+ false
+ );
+ expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(false);
+ expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(false);
+ expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false);
+ });
+
+ test('it toggles query when icon is clicked', () => {
+ const mockToggle = jest.fn();
+ const wrapper = mount(
+
+
+ {'Test children'}
+
+
+ );
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockToggle).toBeCalledWith(false);
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx
index ae07a03ba6407..7997dfa83e27b 100644
--- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx
@@ -5,13 +5,21 @@
* 2.0.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle, EuiTitleSize } from '@elastic/eui';
-import React from 'react';
+import {
+ EuiButtonIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiIconTip,
+ EuiTitle,
+ EuiTitleSize,
+} from '@elastic/eui';
+import React, { useCallback } from 'react';
import styled, { css } from 'styled-components';
import { InspectButton } from '../inspect';
import { Subtitle } from '../subtitle';
+import * as i18n from '../../containers/query_toggle/translations';
interface HeaderProps {
border?: boolean;
@@ -51,6 +59,8 @@ export interface HeaderSectionProps extends HeaderProps {
split?: boolean;
stackHeader?: boolean;
subtitle?: string | React.ReactNode;
+ toggleQuery?: (status: boolean) => void;
+ toggleStatus?: boolean;
title: string | React.ReactNode;
titleSize?: EuiTitleSize;
tooltip?: string;
@@ -72,56 +82,87 @@ const HeaderSectionComponent: React.FC = ({
subtitle,
title,
titleSize = 'm',
+ toggleQuery,
+ toggleStatus = true,
tooltip,
-}) => (
-
-
-
-
-
-
-
- {title}
- {tooltip && (
- <>
- {' '}
-
- >
+}) => {
+ const toggle = useCallback(() => {
+ if (toggleQuery) {
+ toggleQuery(!toggleStatus);
+ }
+ }, [toggleQuery, toggleStatus]);
+ return (
+
+
+
+
+
+
+ {toggleQuery && (
+
+
+
)}
-
-
+
+
+
+ {title}
+ {tooltip && (
+ <>
+ {' '}
+
+ >
+ )}
+
+
+
+
- {!hideSubtitle && (
-
- )}
-
-
- {id && showInspectButton && (
-
-
+ {!hideSubtitle && toggleStatus && (
+
+ )}
- )}
- {headerFilters && {headerFilters}}
-
-
+ {id && showInspectButton && toggleStatus && (
+
+
+
+ )}
- {children && (
-
- {children}
+ {headerFilters && toggleStatus && (
+
+ {headerFilters}
+
+ )}
+
- )}
-
-
-);
+
+ {children && toggleStatus && (
+
+ {children}
+
+ )}
+
+
+ );
+};
export const HeaderSection = React.memo(HeaderSectionComponent);
diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx
index 3f34b857615fe..6a83edd7442de 100644
--- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx
@@ -6,6 +6,9 @@
*/
import { appendSearch } from './helpers';
+import { LANDING_PATH } from '../../../../common/constants';
export const getAppOverviewUrl = (overviewPath: string, search?: string) =>
`${overviewPath}${appendSearch(search)}`;
+
+export const getAppLandingUrl = (search?: string) => `${LANDING_PATH}${appendSearch(search)}`;
diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx
index aee49bd1b00ae..1de9e08b4c65c 100644
--- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx
@@ -15,6 +15,9 @@ import { TestProviders } from '../../mock';
import { mockRuntimeMappings } from '../../containers/source/mock';
import { dnsTopDomainsLensAttributes } from '../visualization_actions/lens_attributes/network/dns_top_domains';
import { useRouteSpy } from '../../utils/route/use_route_spy';
+import { useQueryToggle } from '../../containers/query_toggle';
+
+jest.mock('../../containers/query_toggle');
jest.mock('../../lib/kibana');
jest.mock('./matrix_loader', () => ({
@@ -25,9 +28,7 @@ jest.mock('../charts/barchart', () => ({
BarChart: () => ,
}));
-jest.mock('../../containers/matrix_histogram', () => ({
- useMatrixHistogramCombined: jest.fn(),
-}));
+jest.mock('../../containers/matrix_histogram');
jest.mock('../visualization_actions', () => ({
VisualizationActions: jest.fn(({ className }: { className: string }) => (
@@ -78,9 +79,13 @@ describe('Matrix Histogram Component', () => {
title: 'mockTitle',
runtimeMappings: mockRuntimeMappings,
};
-
- beforeAll(() => {
- (useMatrixHistogramCombined as jest.Mock).mockReturnValue([
+ const mockUseMatrix = useMatrixHistogramCombined as jest.Mock;
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ const mockSetToggle = jest.fn();
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
+ mockUseMatrix.mockReturnValue([
false,
{
data: null,
@@ -88,14 +93,16 @@ describe('Matrix Histogram Component', () => {
totalCount: null,
},
]);
- wrapper = mount(, {
- wrappingComponent: TestProviders,
- });
});
describe('on initial load', () => {
+ beforeEach(() => {
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ });
test('it requests Matrix Histogram', () => {
- expect(useMatrixHistogramCombined).toHaveBeenCalledWith({
+ expect(mockUseMatrix).toHaveBeenCalledWith({
endDate: mockMatrixOverTimeHistogramProps.endDate,
errorMessage: mockMatrixOverTimeHistogramProps.errorMessage,
histogramType: mockMatrixOverTimeHistogramProps.histogramType,
@@ -114,6 +121,9 @@ describe('Matrix Histogram Component', () => {
describe('spacer', () => {
test('it renders a spacer by default', () => {
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
expect(wrapper.find('[data-test-subj="spacer"]').exists()).toBe(true);
});
@@ -129,8 +139,11 @@ describe('Matrix Histogram Component', () => {
});
describe('not initial load', () => {
- beforeAll(() => {
- (useMatrixHistogramCombined as jest.Mock).mockReturnValue([
+ beforeEach(() => {
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ mockUseMatrix.mockReturnValue([
false,
{
data: [
@@ -159,6 +172,9 @@ describe('Matrix Histogram Component', () => {
describe('select dropdown', () => {
test('should be hidden if only one option is provided', () => {
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
expect(wrapper.find('EuiSelect').exists()).toBe(false);
});
});
@@ -287,4 +303,53 @@ describe('Matrix Histogram Component', () => {
expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(false);
});
});
+
+ describe('toggle query', () => {
+ const testProps = {
+ ...mockMatrixOverTimeHistogramProps,
+ lensAttributes: dnsTopDomainsLensAttributes,
+ };
+
+ test('toggleQuery updates toggleStatus', () => {
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false);
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ expect(mockUseMatrix.mock.calls[1][0].skip).toEqual(true);
+ });
+
+ test('toggleStatus=true, do not skip', () => {
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false);
+ });
+
+ test('toggleStatus=true, render components', () => {
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('MatrixLoader').exists()).toBe(true);
+ });
+
+ test('toggleStatus=false, do not render components', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('MatrixLoader').exists()).toBe(false);
+ });
+
+ test('toggleStatus=false, skip', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(true);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx
index dbf525f8e14cb..488948de074f6 100644
--- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx
@@ -34,6 +34,7 @@ import { GetLensAttributes, LensAttributes } from '../visualization_actions/type
import { useKibana, useGetUserCasesPermissions } from '../../lib/kibana';
import { APP_ID, SecurityPageName } from '../../../../common/constants';
import { useRouteSpy } from '../../utils/route/use_route_spy';
+import { useQueryToggle } from '../../containers/query_toggle';
export type MatrixHistogramComponentProps = MatrixHistogramProps &
Omit & {
@@ -148,6 +149,19 @@ export const MatrixHistogramComponent: React.FC =
},
[defaultStackByOption, stackByOptions]
);
+ const { toggleStatus, setToggleStatus } = useQueryToggle(id);
+ const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
+ useEffect(() => {
+ setQuerySkip(skip || !toggleStatus);
+ }, [skip, toggleStatus]);
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
const matrixHistogramRequest = {
endDate,
@@ -161,9 +175,8 @@ export const MatrixHistogramComponent: React.FC =
runtimeMappings,
isPtrIncluded,
docValueFields,
- skip,
+ skip: querySkip,
};
-
const [loading, { data, inspect, totalCount, refetch }] =
useMatrixHistogramCombined(matrixHistogramRequest);
const [{ pageName }] = useRouteSpy();
@@ -225,7 +238,7 @@ export const MatrixHistogramComponent: React.FC =
>
{loading && !isInitialLoading && (
@@ -239,8 +252,11 @@ export const MatrixHistogramComponent: React.FC =
=
{headerChildren}
-
- {isInitialLoading ? (
-
- ) : (
-
- )}
+ {toggleStatus ? (
+ isInitialLoading ? (
+
+ ) : (
+
+ )
+ ) : null}
{showSpacer && }
diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx
index efa4ba4c6eb0f..8eca508a4b74b 100644
--- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx
@@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import styled from 'styled-components';
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
- flex 1;
+ flex: 1;
`;
const MatrixLoaderComponent = () => (
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts
index f1cab9c2f441d..58610298d4395 100644
--- a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts
+++ b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts
@@ -80,7 +80,9 @@ export const useAnomaliesTableData = ({
earliestMs: number,
latestMs: number
) {
- if (isMlUser && !skip && jobIds.length > 0) {
+ if (skip) {
+ setLoading(false);
+ } else if (isMlUser && !skip && jobIds.length > 0) {
try {
const data = await anomaliesTableData(
{
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx
new file mode 100644
index 0000000000000..7701880bd7b2e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx
@@ -0,0 +1,88 @@
+/*
+ * 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 { mount } from 'enzyme';
+import { AnomaliesHostTable } from './anomalies_host_table';
+import { TestProviders } from '../../../mock';
+import React from 'react';
+import { useQueryToggle } from '../../../containers/query_toggle';
+import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
+import { HostsType } from '../../../../hosts/store/model';
+import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
+
+jest.mock('../../../containers/query_toggle');
+jest.mock('../anomaly/use_anomalies_table_data');
+jest.mock('../../../../../common/machine_learning/has_ml_user_permissions');
+
+describe('Anomalies host table', () => {
+ describe('toggle query', () => {
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock;
+ const mockSetToggle = jest.fn();
+ const testProps = {
+ startDate: '2019-07-17T20:00:00.000Z',
+ endDate: '2019-07-18T20:00:00.000Z',
+ narrowDateRange: jest.fn(),
+ skip: false,
+ type: HostsType.page,
+ };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (hasMlUserPermissions as jest.Mock).mockReturnValue(true);
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
+ mockUseAnomaliesTableData.mockReturnValue([
+ false,
+ {
+ anomalies: [],
+ interval: '10',
+ },
+ ]);
+ });
+
+ test('toggleQuery updates toggleStatus', () => {
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false);
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true);
+ });
+
+ test('toggleStatus=true, do not skip', () => {
+ mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false);
+ });
+
+ test('toggleStatus=true, render components', () => {
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(true);
+ });
+
+ test('toggleStatus=false, do not render components', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(false);
+ });
+
+ test('toggleStatus=false, skip', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx
index 318f452e0c1df..eec90e6117c28 100644
--- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
@@ -21,6 +21,7 @@ import { BasicTable } from './basic_table';
import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type';
import { Panel } from '../../panel';
import { anomaliesTableDefaultEquality } from './default_equality';
+import { useQueryToggle } from '../../../containers/query_toggle';
const sorting = {
sort: {
@@ -37,10 +38,24 @@ const AnomaliesHostTableComponent: React.FC = ({
type,
}) => {
const capabilities = useMlCapabilities();
+ const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesHostTable`);
+ const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
+ useEffect(() => {
+ setQuerySkip(skip || !toggleStatus);
+ }, [skip, toggleStatus]);
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
+
const [loading, tableData] = useAnomaliesTableData({
startDate,
endDate,
- skip,
+ skip: querySkip,
criteriaFields: getCriteriaFromHostType(type, hostName),
filterQuery: {
exists: { field: 'host.name' },
@@ -64,21 +79,26 @@ const AnomaliesHostTableComponent: React.FC = ({
return (
-
- type is not as specific as EUI's...
- columns={columns}
- items={hosts}
- pagination={pagination}
- sorting={sorting}
- />
+ {toggleStatus && (
+ type is not as specific as EUI's...
+ columns={columns}
+ items={hosts}
+ pagination={pagination}
+ sorting={sorting}
+ />
+ )}
{loading && (
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx
new file mode 100644
index 0000000000000..b7491562a5d72
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx
@@ -0,0 +1,90 @@
+/*
+ * 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 { mount } from 'enzyme';
+import { AnomaliesNetworkTable } from './anomalies_network_table';
+import { TestProviders } from '../../../mock';
+import React from 'react';
+import { useQueryToggle } from '../../../containers/query_toggle';
+import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
+import { NetworkType } from '../../../../network/store/model';
+import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
+import { FlowTarget } from '../../../../../common/search_strategy';
+
+jest.mock('../../../containers/query_toggle');
+jest.mock('../anomaly/use_anomalies_table_data');
+jest.mock('../../../../../common/machine_learning/has_ml_user_permissions');
+
+describe('Anomalies network table', () => {
+ describe('toggle query', () => {
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock;
+ const mockSetToggle = jest.fn();
+ const testProps = {
+ startDate: '2019-07-17T20:00:00.000Z',
+ endDate: '2019-07-18T20:00:00.000Z',
+ flowTarget: FlowTarget.destination,
+ narrowDateRange: jest.fn(),
+ skip: false,
+ type: NetworkType.page,
+ };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (hasMlUserPermissions as jest.Mock).mockReturnValue(true);
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
+ mockUseAnomaliesTableData.mockReturnValue([
+ false,
+ {
+ anomalies: [],
+ interval: '10',
+ },
+ ]);
+ });
+
+ test('toggleQuery updates toggleStatus', () => {
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false);
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true);
+ });
+
+ test('toggleStatus=true, do not skip', () => {
+ mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false);
+ });
+
+ test('toggleStatus=true, render components', () => {
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(true);
+ });
+
+ test('toggleStatus=false, do not render components', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(false);
+ });
+
+ test('toggleStatus=false, skip', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx
index 78795c6d3614a..242114a806ca8 100644
--- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
@@ -20,6 +20,7 @@ import { BasicTable } from './basic_table';
import { networkEquality } from './network_equality';
import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type';
import { Panel } from '../../panel';
+import { useQueryToggle } from '../../../containers/query_toggle';
const sorting = {
sort: {
@@ -37,10 +38,25 @@ const AnomaliesNetworkTableComponent: React.FC = ({
flowTarget,
}) => {
const capabilities = useMlCapabilities();
+
+ const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesNetwork-${flowTarget}`);
+ const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
+ useEffect(() => {
+ setQuerySkip(skip || !toggleStatus);
+ }, [skip, toggleStatus]);
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
+
const [loading, tableData] = useAnomaliesTableData({
startDate,
endDate,
- skip,
+ skip: querySkip,
criteriaFields: getCriteriaFromNetworkType(type, ip, flowTarget),
});
@@ -63,18 +79,23 @@ const AnomaliesNetworkTableComponent: React.FC = ({
subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT(
pagination.totalItemCount
)}`}
+ height={!toggleStatus ? 40 : undefined}
title={i18n.ANOMALIES}
tooltip={i18n.TOOLTIP}
+ toggleQuery={toggleQuery}
+ toggleStatus={toggleStatus}
isInspectDisabled={skip}
/>
-
- type is not as specific as EUI's...
- columns={columns}
- items={networks}
- pagination={pagination}
- sorting={sorting}
- />
+ {toggleStatus && (
+ type is not as specific as EUI's...
+ columns={columns}
+ items={networks}
+ pagination={pagination}
+ sorting={sorting}
+ />
+ )}
{loading && (
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx
new file mode 100644
index 0000000000000..40aab638b854a
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx
@@ -0,0 +1,89 @@
+/*
+ * 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 { mount } from 'enzyme';
+import { AnomaliesUserTable } from './anomalies_user_table';
+import { TestProviders } from '../../../mock';
+import React from 'react';
+import { useQueryToggle } from '../../../containers/query_toggle';
+import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
+import { UsersType } from '../../../../users/store/model';
+import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
+
+jest.mock('../../../containers/query_toggle');
+jest.mock('../anomaly/use_anomalies_table_data');
+jest.mock('../../../../../common/machine_learning/has_ml_user_permissions');
+
+describe('Anomalies user table', () => {
+ describe('toggle query', () => {
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock;
+ const mockSetToggle = jest.fn();
+ const testProps = {
+ startDate: '2019-07-17T20:00:00.000Z',
+ endDate: '2019-07-18T20:00:00.000Z',
+ narrowDateRange: jest.fn(),
+ userName: 'coolguy',
+ skip: false,
+ type: UsersType.page,
+ };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (hasMlUserPermissions as jest.Mock).mockReturnValue(true);
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
+ mockUseAnomaliesTableData.mockReturnValue([
+ false,
+ {
+ anomalies: [],
+ interval: '10',
+ },
+ ]);
+ });
+
+ test('toggleQuery updates toggleStatus', () => {
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false);
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true);
+ });
+
+ test('toggleStatus=true, do not skip', () => {
+ mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false);
+ });
+
+ test('toggleStatus=true, render components', () => {
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(true);
+ });
+
+ test('toggleStatus=false, do not render components', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ const wrapper = mount(, {
+ wrappingComponent: TestProviders,
+ });
+ expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(false);
+ });
+
+ test('toggleStatus=false, skip', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ mount(, {
+ wrappingComponent: TestProviders,
+ });
+
+ expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true);
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx
index 061f2c04cef6d..c67455c0772b9 100644
--- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data';
import { HeaderSection } from '../../header_section';
@@ -23,6 +23,7 @@ import { Panel } from '../../panel';
import { anomaliesTableDefaultEquality } from './default_equality';
import { convertAnomaliesToUsers } from './convert_anomalies_to_users';
import { getAnomaliesUserTableColumnsCurated } from './get_anomalies_user_table_columns';
+import { useQueryToggle } from '../../../containers/query_toggle';
const sorting = {
sort: {
@@ -40,10 +41,24 @@ const AnomaliesUserTableComponent: React.FC = ({
}) => {
const capabilities = useMlCapabilities();
+ const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesUserTable`);
+ const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
+ useEffect(() => {
+ setQuerySkip(skip || !toggleStatus);
+ }, [skip, toggleStatus]);
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
+
const [loading, tableData] = useAnomaliesTableData({
startDate,
endDate,
- skip,
+ skip: querySkip,
criteriaFields: getCriteriaFromUsersType(type, userName),
filterQuery: {
exists: { field: 'user.name' },
@@ -67,21 +82,27 @@ const AnomaliesUserTableComponent: React.FC = ({
return (
- type is not as specific as EUI's...
- columns={columns}
- items={users}
- pagination={pagination}
- sorting={sorting}
- />
+ {toggleStatus && (
+ type is not as specific as EUI's...
+ columns={columns}
+ items={users}
+ pagination={pagination}
+ sorting={sorting}
+ />
+ )}
{loading && (
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts
index 0a4f12e348eff..b1903ef869d3d 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts
@@ -47,6 +47,7 @@ export type SecurityNavKey =
| SecurityPageName.detectionAndResponse
| SecurityPageName.case
| SecurityPageName.endpoints
+ | SecurityPageName.landing
| SecurityPageName.policies
| SecurityPageName.eventFilters
| SecurityPageName.exceptions
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx
index a00ea4b6bf520..601794dd25917 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx
@@ -125,6 +125,16 @@ describe('useSecuritySolutionNavigation', () => {
"name": "Overview",
"onClick": [Function],
},
+ Object {
+ "data-href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))",
+ "data-test-subj": "navigation-get_started",
+ "disabled": false,
+ "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))",
+ "id": "get_started",
+ "isSelected": false,
+ "name": "Getting started",
+ "onClick": [Function],
+ },
],
"name": "",
},
@@ -286,8 +296,7 @@ describe('useSecuritySolutionNavigation', () => {
() => useSecuritySolutionNavigation(),
{ wrapper: TestProviders }
);
-
- expect(result?.current?.items?.[0].items?.[1].id).toEqual(
+ expect(result?.current?.items?.[0].items?.[2].id).toEqual(
SecurityPageName.detectionAndResponse
);
});
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx
index 677632d20e718..14b007be4764d 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx
@@ -78,6 +78,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) {
name: '',
items: [
navTabs[SecurityPageName.overview],
+ navTabs[SecurityPageName.landing],
// Temporary check for detectionAndResponse while page is feature flagged
...(navTabs[SecurityPageName.detectionAndResponse] != null
? [navTabs[SecurityPageName.detectionAndResponse]]
diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap
index a2fffc32be46d..bf03d637e8811 100644
--- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap
@@ -11,6 +11,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta
@@ -58,6 +60,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta
},
]
}
+ data-test-subj="paginated-basic-table"
items={
Array [
Object {
diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx
index 0c09dce9c07cb..57686126dfb10 100644
--- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx
@@ -15,6 +15,8 @@ import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock';
import { ThemeProvider } from 'styled-components';
import { getMockTheme } from '../../lib/kibana/kibana_react.mock';
import { Direction } from '../../../../common/search_strategy';
+import { useQueryToggle } from '../../containers/query_toggle';
+jest.mock('../../containers/query_toggle');
jest.mock('react', () => {
const r = jest.requireActual('react');
@@ -36,37 +38,41 @@ const mockTheme = getMockTheme({
});
describe('Paginated Table Component', () => {
- let loadPage: jest.Mock;
- let updateLimitPagination: jest.Mock;
- let updateActivePage: jest.Mock;
+ const loadPage = jest.fn();
+ const updateLimitPagination = jest.fn();
+ const updateActivePage = jest.fn();
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ const mockSetToggle = jest.fn();
+ const mockSetQuerySkip = jest.fn();
+
beforeEach(() => {
- loadPage = jest.fn();
- updateLimitPagination = jest.fn();
- updateActivePage = jest.fn();
+ jest.clearAllMocks();
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
});
+ const testProps = {
+ activePage: 0,
+ columns: getHostsColumns(),
+ headerCount: 1,
+ headerSupplement: {'My test supplement.'}
,
+ headerTitle: 'Hosts',
+ headerTooltip: 'My test tooltip',
+ headerUnit: 'Test Unit',
+ itemsPerRow: rowItems,
+ limit: 1,
+ loading: false,
+ loadPage,
+ pageOfItems: mockData.Hosts.edges,
+ setQuerySkip: jest.fn(),
+ showMorePagesIndicator: true,
+ totalCount: 10,
+ updateActivePage,
+ updateLimitPagination: (limit: number) => updateLimitPagination({ limit }),
+ };
+
describe('rendering', () => {
test('it renders the default load more table', () => {
- const wrapper = shallow(
- {'My test supplement.'}
}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={1}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
- );
+ const wrapper = shallow();
expect(wrapper).toMatchSnapshot();
});
@@ -74,24 +80,7 @@ describe('Paginated Table Component', () => {
test('it renders the loading panel at the beginning ', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={1}
- loading={true}
- loadPage={loadPage}
- pageOfItems={[]}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
@@ -103,24 +92,7 @@ describe('Paginated Table Component', () => {
test('it renders the over loading panel after data has been in the table ', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={1}
- loading={true}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
@@ -130,24 +102,7 @@ describe('Paginated Table Component', () => {
test('it renders the correct amount of pages and starts at activePage: 0', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={1}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
@@ -167,24 +122,7 @@ describe('Paginated Table Component', () => {
test('it render popover to select new limit in table', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={2}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
@@ -195,24 +133,7 @@ describe('Paginated Table Component', () => {
test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={[]}
- limit={2}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
@@ -224,24 +145,11 @@ describe('Paginated Table Component', () => {
const wrapper = mount(
{'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
limit={2}
- loading={false}
- loadPage={jest.fn()}
onChange={mockOnChange}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
sorting={{ direction: Direction.asc, field: 'node.host.name' }}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
/>
);
@@ -253,22 +161,9 @@ describe('Paginated Table Component', () => {
const wrapper = mount(
{'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
+ {...testProps}
limit={DEFAULT_MAX_TABLE_QUERY_SIZE}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
totalCount={DEFAULT_MAX_TABLE_QUERY_SIZE * 3}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
/>
);
@@ -279,24 +174,7 @@ describe('Paginated Table Component', () => {
test('Should show items per row if totalCount is greater than items', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={DEFAULT_MAX_TABLE_QUERY_SIZE}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={30}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeTruthy();
@@ -305,24 +183,7 @@ describe('Paginated Table Component', () => {
test('Should hide items per row if totalCount is less than items', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={DEFAULT_MAX_TABLE_QUERY_SIZE}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={1}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy();
@@ -331,24 +192,7 @@ describe('Paginated Table Component', () => {
test('Should hide pagination if totalCount is zero', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={DEFAULT_MAX_TABLE_QUERY_SIZE}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={0}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
@@ -360,24 +204,7 @@ describe('Paginated Table Component', () => {
test('should call updateActivePage with 1 when clicking to the first page', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={1}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click');
@@ -387,24 +214,7 @@ describe('Paginated Table Component', () => {
test('Should call updateActivePage with 0 when you pick a new limit', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={2}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click');
@@ -417,22 +227,8 @@ describe('Paginated Table Component', () => {
test('should update the page when the activePage is changed from redux', () => {
const ourProps: BasicTableProps = {
+ ...testProps,
activePage: 3,
- columns: getHostsColumns(),
- headerCount: 1,
- headerSupplement: {'My test supplement.'}
,
- headerTitle: 'Hosts',
- headerTooltip: 'My test tooltip',
- headerUnit: 'Test Unit',
- itemsPerRow: rowItems,
- limit: 1,
- loading: false,
- loadPage,
- pageOfItems: mockData.Hosts.edges,
- showMorePagesIndicator: true,
- totalCount: 10,
- updateActivePage,
- updateLimitPagination: (limit) => updateLimitPagination({ limit }),
};
// enzyme does not allow us to pass props to child of HOC
@@ -462,24 +258,7 @@ describe('Paginated Table Component', () => {
test('Should call updateLimitPagination when you pick a new limit', () => {
const wrapper = mount(
- {'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
- limit={2}
- loading={false}
- loadPage={loadPage}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
- />
+
);
@@ -494,24 +273,11 @@ describe('Paginated Table Component', () => {
const wrapper = mount(
{'My test supplement.'}}
- headerTitle="Hosts"
- headerTooltip="My test tooltip"
- headerUnit="Test Unit"
- itemsPerRow={rowItems}
limit={2}
- loading={false}
- loadPage={jest.fn()}
onChange={mockOnChange}
- pageOfItems={mockData.Hosts.edges}
- showMorePagesIndicator={true}
sorting={{ direction: Direction.asc, field: 'node.host.name' }}
- totalCount={10}
- updateActivePage={updateActivePage}
- updateLimitPagination={(limit) => updateLimitPagination({ limit })}
/>
);
@@ -524,4 +290,41 @@ describe('Paginated Table Component', () => {
]);
});
});
+
+ describe('Toggle query', () => {
+ test('toggleQuery updates toggleStatus', () => {
+ const wrapper = mount(
+
+
+
+ );
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ expect(mockSetQuerySkip).toBeCalledWith(true);
+ });
+
+ test('toggleStatus=true, render table', () => {
+ const wrapper = mount(
+
+
+
+ );
+ expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual(
+ true
+ );
+ });
+
+ test('toggleStatus=false, hide table', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+
+ const wrapper = mount(
+
+
+
+ );
+ expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual(
+ false
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx
index 310ab039057c2..b9de144c5735e 100644
--- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx
@@ -20,7 +20,7 @@ import {
EuiTableRowCellProps,
} from '@elastic/eui';
import { noop } from 'lodash/fp';
-import React, { FC, memo, useState, useMemo, useEffect, ComponentType } from 'react';
+import React, { FC, memo, useState, useMemo, useEffect, ComponentType, useCallback } from 'react';
import styled from 'styled-components';
import { Direction } from '../../../../common/search_strategy';
@@ -49,6 +49,7 @@ import { useStateToaster } from '../toasters';
import * as i18n from './translations';
import { Panel } from '../panel';
import { InspectButtonContainer } from '../inspect';
+import { useQueryToggle } from '../../containers/query_toggle';
const DEFAULT_DATA_TEST_SUBJ = 'paginated-table';
@@ -113,6 +114,7 @@ export interface BasicTableProps {
onChange?: (criteria: Criteria) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pageOfItems: any[];
+ setQuerySkip: (skip: boolean) => void;
showMorePagesIndicator: boolean;
sorting?: SortingBasicTable;
split?: boolean;
@@ -153,6 +155,7 @@ const PaginatedTableComponent: FC = ({
loadPage,
onChange = noop,
pageOfItems,
+ setQuerySkip,
showMorePagesIndicator,
sorting = null,
split,
@@ -253,10 +256,24 @@ const PaginatedTableComponent: FC = ({
[sorting]
);
+ const { toggleStatus, setToggleStatus } = useQueryToggle(id);
+
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
+
return (
= ({
>
{!loadingInitial && headerSupplement}
-
- {loadingInitial ? (
-
- ) : (
- <>
-
-
-
- {itemsPerRow && itemsPerRow.length > 0 && totalCount >= itemsPerRow[0].numberOfRow && (
-
- )}
-
-
-
- {totalCount > 0 && (
-
- )}
-
-
- {(isInspect || myLoading) && (
-
- )}
- >
- )}
+ {toggleStatus &&
+ (loadingInitial ? (
+
+ ) : (
+ <>
+
+
+
+ {itemsPerRow &&
+ itemsPerRow.length > 0 &&
+ totalCount >= itemsPerRow[0].numberOfRow && (
+
+ )}
+
+
+
+ {totalCount > 0 && (
+
+ )}
+
+
+ {(isInspect || myLoading) && (
+
+ )}
+ >
+ ))}
);
diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx
index 5f2c76632aba9..944eeb8b42a57 100644
--- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx
@@ -41,6 +41,7 @@ import {
NetworkKpiStrategyResponse,
} from '../../../../common/search_strategy';
import { getMockTheme } from '../../lib/kibana/kibana_react.mock';
+import * as module from '../../containers/query_toggle';
const from = '2019-06-15T06:00:00.000Z';
const to = '2019-06-18T06:00:00.000Z';
@@ -53,26 +54,37 @@ jest.mock('../charts/barchart', () => {
return { BarChart: () => };
});
+const mockSetToggle = jest.fn();
+
+jest
+ .spyOn(module, 'useQueryToggle')
+ .mockImplementation(() => ({ toggleStatus: true, setToggleStatus: mockSetToggle }));
+const mockSetQuerySkip = jest.fn();
describe('Stat Items Component', () => {
const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } });
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
-
+ const testProps = {
+ description: 'HOSTS',
+ fields: [{ key: 'hosts', value: null, color: '#6092C0', icon: 'cross' }],
+ from,
+ id: 'statItems',
+ key: 'mock-keys',
+ loading: false,
+ setQuerySkip: mockSetQuerySkip,
+ to,
+ narrowDateRange: mockNarrowDateRange,
+ };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
describe.each([
[
mount(
-
+
),
@@ -81,17 +93,7 @@ describe('Stat Items Component', () => {
mount(
-
+
),
@@ -118,62 +120,59 @@ describe('Stat Items Component', () => {
});
});
+ const mockStatItemsData: StatItemsProps = {
+ ...testProps,
+ areaChart: [
+ {
+ key: 'uniqueSourceIpsHistogram',
+ value: [
+ { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 },
+ { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 },
+ { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 },
+ ],
+ color: '#D36086',
+ },
+ {
+ key: 'uniqueDestinationIpsHistogram',
+ value: [
+ { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 },
+ { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 },
+ { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 },
+ ],
+ color: '#9170B8',
+ },
+ ],
+ barChart: [
+ { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' },
+ {
+ key: 'uniqueDestinationIps',
+ value: [{ x: 'uniqueDestinationIps', y: 2354 }],
+ color: '#9170B8',
+ },
+ ],
+ description: 'UNIQUE_PRIVATE_IPS',
+ enableAreaChart: true,
+ enableBarChart: true,
+ fields: [
+ {
+ key: 'uniqueSourceIps',
+ description: 'Source',
+ value: 1714,
+ color: '#D36086',
+ icon: 'cross',
+ },
+ {
+ key: 'uniqueDestinationIps',
+ description: 'Dest.',
+ value: 2359,
+ color: '#9170B8',
+ icon: 'cross',
+ },
+ ],
+ };
+
+ let wrapper: ReactWrapper;
describe('rendering kpis with charts', () => {
- const mockStatItemsData: StatItemsProps = {
- areaChart: [
- {
- key: 'uniqueSourceIpsHistogram',
- value: [
- { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 },
- { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 },
- { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 },
- ],
- color: '#D36086',
- },
- {
- key: 'uniqueDestinationIpsHistogram',
- value: [
- { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 },
- { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 },
- { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 },
- ],
- color: '#9170B8',
- },
- ],
- barChart: [
- { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' },
- {
- key: 'uniqueDestinationIps',
- value: [{ x: 'uniqueDestinationIps', y: 2354 }],
- color: '#9170B8',
- },
- ],
- description: 'UNIQUE_PRIVATE_IPS',
- enableAreaChart: true,
- enableBarChart: true,
- fields: [
- {
- key: 'uniqueSourceIps',
- description: 'Source',
- value: 1714,
- color: '#D36086',
- icon: 'cross',
- },
- {
- key: 'uniqueDestinationIps',
- description: 'Dest.',
- value: 2359,
- color: '#9170B8',
- icon: 'cross',
- },
- ],
- from,
- id: 'statItems',
- key: 'mock-keys',
- to,
- narrowDateRange: mockNarrowDateRange,
- };
- let wrapper: ReactWrapper;
beforeAll(() => {
wrapper = mount(
@@ -202,6 +201,43 @@ describe('Stat Items Component', () => {
expect(wrapper.find(EuiHorizontalRule)).toHaveLength(1);
});
});
+ describe('Toggle query', () => {
+ test('toggleQuery updates toggleStatus', () => {
+ wrapper = mount(
+
+
+
+ );
+ wrapper.find('[data-test-subj="query-toggle-stat"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ expect(mockSetQuerySkip).toBeCalledWith(true);
+ });
+ test('toggleStatus=true, render all', () => {
+ wrapper = mount(
+
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual(true);
+ expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(true);
+ });
+ test('toggleStatus=false, render none', () => {
+ jest
+ .spyOn(module, 'useQueryToggle')
+ .mockImplementation(() => ({ toggleStatus: false, setToggleStatus: mockSetToggle }));
+ wrapper = mount(
+
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual(
+ false
+ );
+ expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(false);
+ });
+ });
});
describe('addValueToFields', () => {
@@ -244,7 +280,9 @@ describe('useKpiMatrixStatus', () => {
'statItem',
from,
to,
- mockNarrowDateRange
+ mockNarrowDateRange,
+ mockSetQuerySkip,
+ false
);
return (
@@ -262,8 +300,10 @@ describe('useKpiMatrixStatus', () => {
>
);
-
- expect(wrapper.find('MockChildComponent').get(0).props).toEqual(mockEnableChartsData);
+ const result = { ...wrapper.find('MockChildComponent').get(0).props };
+ const { setQuerySkip, ...restResult } = result;
+ const { setQuerySkip: a, ...restExpect } = mockEnableChartsData;
+ expect(restResult).toEqual(restExpect);
});
test('it should not append areaChart if enableAreaChart is off', () => {
diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx
index 424920d34e2e8..6de3cc07472bc 100644
--- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx
@@ -12,13 +12,16 @@ import {
EuiPanel,
EuiHorizontalRule,
EuiIcon,
+ EuiButtonIcon,
+ EuiLoadingSpinner,
EuiTitle,
IconType,
} from '@elastic/eui';
import { get, getOr } from 'lodash/fp';
-import React, { useState, useEffect, useMemo } from 'react';
+import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
+import { useQueryToggle } from '../../containers/query_toggle';
import {
HostsKpiStrategyResponse,
@@ -34,6 +37,7 @@ import { InspectButton } from '../inspect';
import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions';
import { HoverVisibilityContainer } from '../hover_visibility_container';
import { LensAttributes } from '../visualization_actions/types';
+import * as i18n from '../../containers/query_toggle/translations';
import { UserskKpiStrategyResponse } from '../../../../common/search_strategy/security_solution/users';
const FlexItem = styled(EuiFlexItem)`
@@ -84,6 +88,8 @@ export interface StatItemsProps extends StatItems {
narrowDateRange: UpdateDateRange;
to: string;
showInspectButton?: boolean;
+ loading: boolean;
+ setQuerySkip: (skip: boolean) => void;
}
export const numberFormatter = (value: string | number): string => value.toLocaleString();
@@ -176,33 +182,27 @@ export const useKpiMatrixStatus = (
id: string,
from: string,
to: string,
- narrowDateRange: UpdateDateRange
-): StatItemsProps[] => {
- const [statItemsProps, setStatItemsProps] = useState(mappings as StatItemsProps[]);
-
- useEffect(() => {
- setStatItemsProps(
- mappings.map((stat) => {
- return {
- ...stat,
- areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined,
- barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined,
- fields: addValueToFields(stat.fields, data),
- id,
- key: `kpi-summary-${stat.key}`,
- statKey: `${stat.key}`,
- from,
- to,
- narrowDateRange,
- };
- })
- );
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [data]);
-
- return statItemsProps;
-};
-
+ narrowDateRange: UpdateDateRange,
+ setQuerySkip: (skip: boolean) => void,
+ loading: boolean
+): StatItemsProps[] =>
+ mappings.map((stat) => ({
+ ...stat,
+ areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined,
+ barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined,
+ fields: addValueToFields(stat.fields, data),
+ id,
+ key: `kpi-summary-${stat.key}`,
+ statKey: `${stat.key}`,
+ from,
+ to,
+ narrowDateRange,
+ setQuerySkip,
+ loading,
+ }));
+const StyledTitle = styled.h6`
+ line-height: 200%;
+`;
export const StatItemsComponent = React.memo(
({
areaChart,
@@ -214,13 +214,15 @@ export const StatItemsComponent = React.memo(
from,
grow,
id,
- showInspectButton,
+ loading = false,
+ showInspectButton = true,
index,
narrowDateRange,
statKey = 'item',
to,
barChartLensAttributes,
areaChartLensAttributes,
+ setQuerySkip,
}) => {
const isBarChartDataAvailable =
barChart &&
@@ -239,101 +241,143 @@ export const StatItemsComponent = React.memo(
[from, to]
);
+ const { toggleStatus, setToggleStatus } = useQueryToggle(id);
+
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
+ const toggle = useCallback(() => toggleQuery(!toggleStatus), [toggleQuery, toggleStatus]);
+
return (
-
- {description}
-
+
+
+
+
+
+
+ {description}
+
+
+
- {showInspectButton && (
+ {showInspectButton && toggleStatus && !loading && (
)}
+ {loading && (
+
+
+
+
+
+ )}
+ {toggleStatus && !loading && (
+ <>
+
+ {fields.map((field) => (
+
+
+ {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && (
+
+
+
+ )}
-
- {fields.map((field) => (
-
-
- {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && (
-
-
-
- )}
+
+
+
+
+ {field.value != null
+ ? field.value.toLocaleString()
+ : getEmptyTagValue()}{' '}
+ {field.description}
+
+
+ {field.lensAttributes && timerange && (
+
+ )}
+
+
+
+
+ ))}
+
+ {(enableAreaChart || enableBarChart) && }
+
+ {enableBarChart && (
-
-
-
- {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '}
- {field.description}
-
-
- {field.lensAttributes && timerange && (
-
- )}
-
+
-
-
- ))}
-
+ )}
- {(enableAreaChart || enableBarChart) && }
-
- {enableBarChart && (
-
-
-
- )}
-
- {enableAreaChart && from != null && to != null && (
- <>
-
-
-
- >
- )}
-
+ {enableAreaChart && from != null && to != null && (
+ <>
+
+
+
+ >
+ )}
+
+ >
+ )}
);
@@ -344,6 +388,8 @@ export const StatItemsComponent = React.memo(
prevProps.enableBarChart === nextProps.enableBarChart &&
prevProps.from === nextProps.from &&
prevProps.grow === nextProps.grow &&
+ prevProps.loading === nextProps.loading &&
+ prevProps.setQuerySkip === nextProps.setQuerySkip &&
prevProps.id === nextProps.id &&
prevProps.index === nextProps.index &&
prevProps.narrowDateRange === nextProps.narrowDateRange &&
diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts
index d8a2db30d4a7e..3b319b810a66e 100644
--- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts
+++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts
@@ -31,6 +31,7 @@ export type UrlStateType =
| 'cases'
| 'detection_response'
| 'exceptions'
+ | 'get_started'
| 'host'
| 'users'
| 'network'
diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
index 559dff64eec4b..e5ce8e4105cac 100644
--- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
+++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
@@ -94,6 +94,9 @@ export const replaceQueryStringInLocation = (
export const getUrlType = (pageName: string): UrlStateType => {
if (pageName === SecurityPageName.overview) {
return 'overview';
+ }
+ if (pageName === SecurityPageName.landing) {
+ return 'get_started';
} else if (pageName === SecurityPageName.hosts) {
return 'host';
} else if (pageName === SecurityPageName.network) {
diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts
index e09dbe23d512a..138fa99ef4074 100644
--- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts
+++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts
@@ -6,7 +6,6 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
-
import { useKibana } from '../../../common/lib/kibana';
import { useMatrixHistogram, useMatrixHistogramCombined } from '.';
import { MatrixHistogramType } from '../../../../common/search_strategy';
@@ -39,6 +38,7 @@ describe('useMatrixHistogram', () => {
indexNames: [],
stackByField: 'event.module',
startDate: new Date(Date.now()).toISOString(),
+ skip: false,
};
afterEach(() => {
@@ -145,6 +145,17 @@ describe('useMatrixHistogram', () => {
mockDnsSearchStrategyResponse.rawResponse.aggregations?.dns_name_query_count.buckets
);
});
+
+ it('skip = true will cancel any running request', () => {
+ const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
+ const localProps = { ...props };
+ const { rerender } = renderHook(() => useMatrixHistogram(localProps), {
+ wrapper: TestProviders,
+ });
+ localProps.skip = true;
+ act(() => rerender());
+ expect(abortSpy).toHaveBeenCalledTimes(3);
+ });
});
describe('useMatrixHistogramCombined', () => {
diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts
index c49a9d0438b2d..f6670c98fc0ee 100644
--- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts
+++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts
@@ -229,6 +229,14 @@ export const useMatrixHistogram = ({
};
}, [matrixHistogramRequest, hostsSearch, skip]);
+ useEffect(() => {
+ if (skip) {
+ setLoading(false);
+ searchSubscription$.current.unsubscribe();
+ abortCtrl.current.abort();
+ }
+ }, [skip]);
+
const runMatrixHistogramSearch = useCallback(
(to: string, from: string) => {
hostsSearch({
diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx
new file mode 100644
index 0000000000000..76f1c02dcb43c
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.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 {
+ renderHook,
+ act,
+ RenderResult,
+ WaitForNextUpdate,
+ cleanup,
+} from '@testing-library/react-hooks';
+import { QueryToggle, useQueryToggle } from '.';
+import { RouteSpyState } from '../../utils/route/types';
+import { SecurityPageName } from '../../../../common/constants';
+import { useKibana } from '../../lib/kibana';
+
+const mockRouteSpy: RouteSpyState = {
+ pageName: SecurityPageName.overview,
+ detailName: undefined,
+ tabName: undefined,
+ search: '',
+ pathName: '/',
+};
+jest.mock('../../lib/kibana');
+jest.mock('../../utils/route/use_route_spy', () => ({
+ useRouteSpy: () => [mockRouteSpy],
+}));
+
+describe('useQueryToggle', () => {
+ let result: RenderResult;
+ let waitForNextUpdate: WaitForNextUpdate;
+ const mockSet = jest.fn();
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ storage: {
+ get: () => true,
+ set: mockSet,
+ },
+ },
+ });
+ });
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('Toggles local storage', async () => {
+ await act(async () => {
+ ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle('queryId')));
+ await waitForNextUpdate();
+ expect(result.current.toggleStatus).toEqual(true);
+ });
+ act(() => {
+ result.current.setToggleStatus(false);
+ });
+ expect(result.current.toggleStatus).toEqual(false);
+ expect(mockSet).toBeCalledWith('kibana.siem:queryId.query.toggle:overview', false);
+ cleanup();
+ });
+ it('null storage key, do not set', async () => {
+ await act(async () => {
+ ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle()));
+ await waitForNextUpdate();
+ expect(result.current.toggleStatus).toEqual(true);
+ });
+ act(() => {
+ result.current.setToggleStatus(false);
+ });
+ expect(mockSet).not.toBeCalled();
+ cleanup();
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx
new file mode 100644
index 0000000000000..53bcd6b60fc1b
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx
@@ -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 { useEffect, useCallback, useState } from 'react';
+import { useKibana } from '../../lib/kibana';
+import { useRouteSpy } from '../../utils/route/use_route_spy';
+
+export const getUniqueStorageKey = (pageName: string, id?: string): string | null =>
+ id && pageName.length > 0 ? `kibana.siem:${id}.query.toggle:${pageName}` : null;
+export interface QueryToggle {
+ toggleStatus: boolean;
+ setToggleStatus: (b: boolean) => void;
+}
+
+export const useQueryToggle = (id?: string): QueryToggle => {
+ const [{ pageName }] = useRouteSpy();
+ const {
+ services: { storage },
+ } = useKibana();
+ const storageKey = getUniqueStorageKey(pageName, id);
+
+ const [storageValue, setStorageValue] = useState(
+ storageKey != null ? storage.get(storageKey) ?? true : true
+ );
+
+ useEffect(() => {
+ if (storageKey != null) {
+ setStorageValue(storage.get(storageKey) ?? true);
+ }
+ }, [storage, storageKey]);
+
+ const setToggleStatus = useCallback(
+ (isOpen: boolean) => {
+ if (storageKey != null) {
+ storage.set(storageKey, isOpen);
+ setStorageValue(isOpen);
+ }
+ },
+ [storage, storageKey]
+ );
+
+ return id
+ ? {
+ toggleStatus: storageValue,
+ setToggleStatus,
+ }
+ : {
+ toggleStatus: true,
+ setToggleStatus: () => {},
+ };
+};
diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx
new file mode 100644
index 0000000000000..acb64e7e6b510
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const QUERY_BUTTON_TITLE = (buttonOn: boolean) =>
+ buttonOn
+ ? i18n.translate('xpack.securitySolution.toggleQuery.on', {
+ defaultMessage: 'Open',
+ })
+ : i18n.translate('xpack.securitySolution.toggleQuery.off', {
+ defaultMessage: 'Closed',
+ });
diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts
index 5bfa9028a0fe8..c1513b7a0485b 100644
--- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts
+++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts
@@ -6,7 +6,7 @@
*/
import { useSearchStrategy } from './index';
-import { renderHook } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react-hooks';
import { useObservable } from '@kbn/securitysolution-hook-utils';
import { FactoryQueryTypes } from '../../../../common/search_strategy';
@@ -200,4 +200,19 @@ describe('useSearchStrategy', () => {
expect(start).toBeCalledWith(expect.objectContaining({ signal }));
});
+ it('skip = true will cancel any running request', () => {
+ const abortSpy = jest.fn();
+ const signal = new AbortController().signal;
+ jest.spyOn(window, 'AbortController').mockReturnValue({ abort: abortSpy, signal });
+ const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes;
+ const localProps = {
+ ...userSearchStrategyProps,
+ skip: false,
+ factoryQueryType,
+ };
+ const { rerender } = renderHook(() => useSearchStrategy(localProps));
+ localProps.skip = true;
+ act(() => rerender());
+ expect(abortSpy).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx
index 77676a83d39b6..234cf039024ba 100644
--- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx
@@ -96,6 +96,7 @@ export const useSearchStrategy = ({
factoryQueryType,
initialResult,
errorMessage,
+ skip = false,
}: {
factoryQueryType: QueryType;
/**
@@ -106,6 +107,7 @@ export const useSearchStrategy = ({
* Message displayed to the user on a Toast when an erro happens.
*/
errorMessage?: string;
+ skip?: boolean;
}) => {
const abortCtrl = useRef(new AbortController());
const { getTransformChangesIfTheyExist } = useTransforms();
@@ -154,6 +156,12 @@ export const useSearchStrategy = ({
};
}, []);
+ useEffect(() => {
+ if (skip) {
+ abortCtrl.current.abort();
+ }
+ }, [skip]);
+
const [formatedResult, inspect] = useMemo(
() => [
result
diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts
index 7795e76c5fbbb..52f0b1a682097 100644
--- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts
+++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts
@@ -203,7 +203,6 @@ export const mockGlobalState: State = {
[usersModel.UsersTableType.allUsers]: {
activePage: 0,
limit: 10,
- // TODO sort: { field: RiskScoreFields.riskScore, direction: Direction.desc },
},
[usersModel.UsersTableType.anomalies]: null,
[usersModel.UsersTableType.risk]: {
@@ -215,11 +214,15 @@ export const mockGlobalState: State = {
},
severitySelection: [],
},
+ [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 },
+ [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 },
},
},
details: {
queries: {
[usersModel.UsersTableType.anomalies]: null,
+ [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 },
+ [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 },
},
},
},
diff --git a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts
index e04d059a515d4..bfd844caad1b4 100644
--- a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts
+++ b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts
@@ -13,7 +13,7 @@ import {
EntryNested,
NestedEntriesArray,
} from '@kbn/securitysolution-io-ts-list-types';
-import { ConditionEntryField, EntryTypes } from '@kbn/securitysolution-utils';
+import { AllConditionEntryFields, EntryFieldType, EntryTypes } from '@kbn/securitysolution-utils';
import { ConditionEntriesMap, ConditionEntry } from '../../../../common/endpoint/types';
@@ -46,12 +46,12 @@ const createEntryNested = (field: string, entries: NestedEntriesArray): EntryNes
return { field, entries, type: 'nested' };
};
-function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray {
+function groupHashEntry(prefix: 'process' | 'file', conditionEntry: ConditionEntry): EntriesArray {
const entriesArray: EntriesArray = [];
if (!Array.isArray(conditionEntry.value)) {
const entry = createEntryMatch(
- `process.hash.${hashType(conditionEntry.value)}`,
+ `${prefix}${EntryFieldType.HASH}${hashType(conditionEntry.value)}`,
conditionEntry.value.toLowerCase()
);
entriesArray.push(entry);
@@ -80,7 +80,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray {
return;
}
- const entry = createEntryMatchAny(`process.hash.${type}`, values);
+ const entry = createEntryMatchAny(`${prefix}${EntryFieldType.HASH}${type}`, values);
entriesArray.push(entry);
});
@@ -88,6 +88,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray {
}
function createNestedSignatureEntry(
+ field: AllConditionEntryFields,
value: string | string[],
isTrustedApp: boolean = false
): EntryNested {
@@ -97,19 +98,23 @@ function createNestedSignatureEntry(
const nestedEntries: EntryNested['entries'] = [];
if (isTrustedApp) nestedEntries.push(createEntryMatch('trusted', 'true'));
nestedEntries.push(subjectNameMatch);
- return createEntryNested('process.Ext.code_signature', nestedEntries);
+ return createEntryNested(field, nestedEntries);
}
-function createWildcardPathEntry(value: string | string[]): EntryMatchWildcard | EntryMatchAny {
+function createWildcardPathEntry(
+ field: AllConditionEntryFields,
+ value: string | string[]
+): EntryMatchWildcard | EntryMatchAny {
return Array.isArray(value)
- ? createEntryMatchAny('process.executable.caseless', value)
- : createEntryMatchWildcard('process.executable.caseless', value);
+ ? createEntryMatchAny(field, value)
+ : createEntryMatchWildcard(field, value);
}
-function createPathEntry(value: string | string[]): EntryMatch | EntryMatchAny {
- return Array.isArray(value)
- ? createEntryMatchAny('process.executable.caseless', value)
- : createEntryMatch('process.executable.caseless', value);
+function createPathEntry(
+ field: AllConditionEntryFields,
+ value: string | string[]
+): EntryMatch | EntryMatchAny {
+ return Array.isArray(value) ? createEntryMatchAny(field, value) : createEntryMatch(field, value);
}
export const conditionEntriesToEntries = (
@@ -119,19 +124,25 @@ export const conditionEntriesToEntries = (
const entriesArray: EntriesArray = [];
conditionEntries.forEach((conditionEntry) => {
- if (conditionEntry.field === ConditionEntryField.HASH) {
- groupHashEntry(conditionEntry).forEach((entry) => entriesArray.push(entry));
- } else if (conditionEntry.field === ConditionEntryField.SIGNER) {
- const entry = createNestedSignatureEntry(conditionEntry.value, isTrustedApp);
+ if (conditionEntry.field.includes(EntryFieldType.HASH)) {
+ const prefix = conditionEntry.field.split('.')[0] as 'process' | 'file';
+ groupHashEntry(prefix, conditionEntry).forEach((entry) => entriesArray.push(entry));
+ } else if (conditionEntry.field.includes(EntryFieldType.SIGNER)) {
+ const entry = createNestedSignatureEntry(
+ conditionEntry.field,
+ conditionEntry.value,
+ isTrustedApp
+ );
entriesArray.push(entry);
} else if (
- conditionEntry.field === ConditionEntryField.PATH &&
+ (conditionEntry.field.includes(EntryFieldType.EXECUTABLE) ||
+ conditionEntry.field.includes(EntryFieldType.PATH)) &&
conditionEntry.type === 'wildcard'
) {
- const entry = createWildcardPathEntry(conditionEntry.value);
+ const entry = createWildcardPathEntry(conditionEntry.field, conditionEntry.value);
entriesArray.push(entry);
} else {
- const entry = createPathEntry(conditionEntry.value);
+ const entry = createPathEntry(conditionEntry.field, conditionEntry.value);
entriesArray.push(entry);
}
});
@@ -140,49 +151,51 @@ export const conditionEntriesToEntries = (
};
const createConditionEntry = (
- field: ConditionEntryField,
+ field: AllConditionEntryFields,
type: EntryTypes,
value: string | string[]
): ConditionEntry => {
return { field, value, type, operator: OPERATOR_VALUE };
};
+function createWildcardHashField(
+ field: string
+): Extract {
+ const prefix = field.split('.')[0] as 'process' | 'file';
+ return `${prefix}${EntryFieldType.HASH}*`;
+}
+
export const entriesToConditionEntriesMap = (
entries: EntriesArray
): ConditionEntriesMap => {
return entries.reduce((memo: ConditionEntriesMap, entry) => {
- if (entry.field.startsWith('process.hash') && entry.type === 'match') {
+ const field = entry.field as AllConditionEntryFields;
+ if (field.includes(EntryFieldType.HASH) && entry.type === 'match') {
+ const wildcardHashField = createWildcardHashField(field);
return {
...memo,
- [ConditionEntryField.HASH]: createConditionEntry(
- ConditionEntryField.HASH,
- entry.type,
- entry.value
- ),
+ [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, entry.value),
} as ConditionEntriesMap;
- } else if (entry.field.startsWith('process.hash') && entry.type === 'match_any') {
- const currentValues = (memo[ConditionEntryField.HASH]?.value as string[]) ?? [];
+ } else if (field.includes(EntryFieldType.HASH) && entry.type === 'match_any') {
+ const wildcardHashField = createWildcardHashField(field);
+ const currentValues = (memo[wildcardHashField]?.value as string[]) ?? [];
return {
...memo,
- [ConditionEntryField.HASH]: createConditionEntry(ConditionEntryField.HASH, entry.type, [
+ [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, [
...currentValues,
...entry.value,
]),
} as ConditionEntriesMap;
} else if (
- entry.field === ConditionEntryField.PATH &&
+ (field.includes(EntryFieldType.EXECUTABLE) || field.includes(EntryFieldType.PATH)) &&
(entry.type === 'match' || entry.type === 'match_any' || entry.type === 'wildcard')
) {
return {
...memo,
- [ConditionEntryField.PATH]: createConditionEntry(
- ConditionEntryField.PATH,
- entry.type,
- entry.value
- ),
+ [field]: createConditionEntry(field, entry.type, entry.value),
} as ConditionEntriesMap;
- } else if (entry.field === ConditionEntryField.SIGNER && entry.type === 'nested') {
+ } else if (field.includes(EntryFieldType.SIGNER) && entry.type === 'nested') {
const subjectNameCondition = entry.entries.find((subEntry): subEntry is EntryMatch => {
return (
subEntry.field === 'subject_name' &&
@@ -193,8 +206,8 @@ export const entriesToConditionEntriesMap = {
const actual = jest.requireActual('react-router-dom');
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
@@ -22,6 +24,12 @@ describe('AlertsCountPanel', () => {
const defaultProps = {
signalIndexName: 'signalIndexName',
};
+ const mockSetToggle = jest.fn();
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
+ });
it('renders correctly', async () => {
await act(async () => {
@@ -54,4 +62,38 @@ describe('AlertsCountPanel', () => {
});
});
});
+ describe('toggleQuery', () => {
+ it('toggles', async () => {
+ await act(async () => {
+ const wrapper = mount(
+
+
+
+ );
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ });
+ });
+ it('toggleStatus=true, render', async () => {
+ await act(async () => {
+ const wrapper = mount(
+
+
+
+ );
+ expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(true);
+ });
+ });
+ it('toggleStatus=false, hide', async () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ await act(async () => {
+ const wrapper = mount(
+
+
+
+ );
+ expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(false);
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx
index 04b8f482fd121..1c0e2144ad9d4 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx
@@ -6,7 +6,7 @@
*/
import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
-import React, { memo, useMemo, useState, useEffect } from 'react';
+import React, { memo, useMemo, useState, useEffect, useCallback } from 'react';
import uuid from 'uuid';
import type { Filter, Query } from '@kbn/es-query';
@@ -24,6 +24,7 @@ import type { AlertsCountAggregation } from './types';
import { DEFAULT_STACK_BY_FIELD } from '../common/config';
import { KpiPanel, StackByComboBox } from '../common/components';
import { useInspectButton } from '../common/hooks';
+import { useQueryToggle } from '../../../../common/containers/query_toggle';
export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count';
@@ -64,6 +65,20 @@ export const AlertsCountPanel = memo(
}
}, [query, filters]);
+ const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_COUNT_ID);
+ const [querySkip, setQuerySkip] = useState(!toggleStatus);
+ useEffect(() => {
+ setQuerySkip(!toggleStatus);
+ }, [toggleStatus]);
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
+
const {
loading: isLoadingAlerts,
data: alertsData,
@@ -80,6 +95,7 @@ export const AlertsCountPanel = memo(
runtimeMappings
),
indexName: signalIndexName,
+ skip: querySkip,
});
useEffect(() => {
@@ -99,21 +115,26 @@ export const AlertsCountPanel = memo(
});
return (
-
-
+
+
-
+ {toggleStatus && (
+
+ )}
);
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx
index 29e18a1c49c12..3135e2e173793 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx
@@ -12,9 +12,13 @@ import { mount } from 'enzyme';
import type { Filter } from '@kbn/es-query';
import { TestProviders } from '../../../../common/mock';
import { SecurityPageName } from '../../../../app/types';
+import { MatrixLoader } from '../../../../common/components/matrix_histogram/matrix_loader';
import { AlertsHistogramPanel } from './index';
import * as helpers from './helpers';
+import { useQueryToggle } from '../../../../common/containers/query_toggle';
+
+jest.mock('../../../../common/containers/query_toggle');
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
@@ -91,6 +95,12 @@ describe('AlertsHistogramPanel', () => {
updateDateRange: jest.fn(),
};
+ const mockSetToggle = jest.fn();
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ beforeEach(() => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
+ });
+
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
@@ -339,4 +349,40 @@ describe('AlertsHistogramPanel', () => {
`);
});
});
+
+ describe('toggleQuery', () => {
+ it('toggles', async () => {
+ await act(async () => {
+ const wrapper = mount(
+
+
+
+ );
+ wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click');
+ expect(mockSetToggle).toBeCalledWith(false);
+ });
+ });
+ it('toggleStatus=true, render', async () => {
+ await act(async () => {
+ const wrapper = mount(
+
+
+
+ );
+
+ expect(wrapper.find(MatrixLoader).exists()).toEqual(true);
+ });
+ });
+ it('toggleStatus=false, hide', async () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
+ await act(async () => {
+ const wrapper = mount(
+
+
+
+ );
+ expect(wrapper.find(MatrixLoader).exists()).toEqual(false);
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx
index 571f656389f6a..84476c3ee6885 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx
@@ -45,6 +45,7 @@ import type { AlertsStackByField } from '../common/types';
import { KpiPanel, StackByComboBox } from '../common/components';
import { useInspectButton } from '../common/hooks';
+import { useQueryToggle } from '../../../../common/containers/query_toggle';
const defaultTotalAlertsObj: AlertsTotal = {
value: 0,
@@ -116,6 +117,19 @@ export const AlertsHistogramPanel = memo(
onlyField == null ? defaultStackByOption : onlyField
);
+ const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_HISTOGRAM_ID);
+ const [querySkip, setQuerySkip] = useState(!toggleStatus);
+ useEffect(() => {
+ setQuerySkip(!toggleStatus);
+ }, [toggleStatus]);
+ const toggleQuery = useCallback(
+ (status: boolean) => {
+ setToggleStatus(status);
+ // toggle on = skipQuery false
+ setQuerySkip(!status);
+ },
+ [setQuerySkip, setToggleStatus]
+ );
const {
loading: isLoadingAlerts,
data: alertsData,
@@ -132,6 +146,7 @@ export const AlertsHistogramPanel = memo(
runtimeMappings
),
indexName: signalIndexName,
+ skip: querySkip,
});
const kibana = useKibana();
@@ -270,17 +285,21 @@ export const AlertsHistogramPanel = memo(
);
return (
-
+
(
- {isInitialLoading ? (
-
- ) : (
-
- )}
+ {toggleStatus ? (
+ isInitialLoading ? (
+
+ ) : (
+
+ )
+ ) : null}
);
diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx
index 6a56f7bc220ac..27f33409ae1a5 100644
--- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx
@@ -12,17 +12,23 @@ import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config';
import { useStackByFields } from './hooks';
import * as i18n from './translations';
-export const KpiPanel = styled(EuiPanel)<{ height?: number }>`
+export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boolean }>`
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
-
- height: ${MOBILE_PANEL_HEIGHT}px;
-
@media only screen and (min-width: ${(props) => props.theme.eui.euiBreakpoints.m}) {
+ ${({ $toggleStatus }) =>
+ $toggleStatus &&
+ `
height: ${PANEL_HEIGHT}px;
+ `}
}
+ ${({ $toggleStatus }) =>
+ $toggleStatus &&
+ `
+ height: ${MOBILE_PANEL_HEIGHT}px;
+ `}
`;
interface StackedBySelectProps {
selected: string;
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx
index 277e2008601dc..5ed7a219e5068 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx
@@ -129,4 +129,22 @@ describe('useQueryAlerts', () => {
});
});
});
+
+ test('skip', async () => {
+ const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
+ await act(async () => {
+ const localProps = { query: mockAlertsQuery, indexName, skip: false };
+ const { rerender, waitForNextUpdate } = renderHook<
+ [object, string],
+ ReturnQueryAlerts
+ >(() => useQueryAlerts(localProps));
+ await waitForNextUpdate();
+ await waitForNextUpdate();
+
+ localProps.skip = true;
+ act(() => rerender());
+ act(() => rerender());
+ expect(abortSpy).toHaveBeenCalledTimes(2);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx
index b2bbcdf277992..2b98987e52675 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx
@@ -94,6 +94,12 @@ export const useQueryAlerts = ({
if (!isEmpty(query) && !skip) {
fetchData();
}
+ if (skip) {
+ setLoading(false);
+ isSubscribed = false;
+ abortCtrl.abort();
+ }
+
return () => {
isSubscribed = false;
abortCtrl.abort();
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx
index 65684a7c7d9de..72984a8bcbe92 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx
@@ -40,6 +40,7 @@ import { userHasPermissions } from '../../helpers';
import { useListsConfig } from '../../../../../containers/detection_engine/lists/use_lists_config';
import { ExceptionsTableItem } from './types';
import { MissingPrivilegesCallOut } from '../../../../../components/callouts/missing_privileges_callout';
+import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../../../../common/endpoint/service/artifacts/constants';
export type Func = () => Promise;
@@ -84,9 +85,7 @@ export const ExceptionListsTable = React.memo(() => {
http,
namespaceTypes: ['single', 'agnostic'],
notifications,
- showTrustedApps: false,
- showEventFilters: false,
- showHostIsolationExceptions: false,
+ hideLists: ALL_ENDPOINT_ARTIFACT_LIST_IDS,
});
const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists({
exceptionLists: exceptions ?? [],
diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap
index ed119568cdcb3..bffd5e2261ad9 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap
@@ -105,6 +105,7 @@ exports[`Authentication Table Component rendering it renders the authentication
isInspect={false}
loadPage={[MockFunction]}
loading={false}
+ setQuerySkip={[MockFunction]}
showMorePagesIndicator={true}
totalCount={54}
type="page"
diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx
index 14dc1769dbd05..2ec333e335639 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx
@@ -45,6 +45,7 @@ describe('Authentication Table Component', () => {
isInspect={false}
loading={false}
loadPage={loadPage}
+ setQuerySkip={jest.fn()}
showMorePagesIndicator={getOr(
false,
'showMorePagesIndicator',
diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx
index 4402f6a210947..2bbda82e15315 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx
+++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx
@@ -43,6 +43,7 @@ interface AuthenticationTableProps {
loadPage: (newActivePage: number) => void;
id: string;
isInspect: boolean;
+ setQuerySkip: (skip: boolean) => void;
showMorePagesIndicator: boolean;
totalCount: number;
type: hostsModel.HostsType;
@@ -78,6 +79,7 @@ const AuthenticationTableComponent: React.FC = ({
isInspect,
loading,
loadPage,
+ setQuerySkip,
showMorePagesIndicator,
totalCount,
type,
@@ -133,6 +135,7 @@ const AuthenticationTableComponent: React.FC = ({
loading={loading}
loadPage={loadPage}
pageOfItems={data}
+ setQuerySkip={setQuerySkip}
showMorePagesIndicator={showMorePagesIndicator}
totalCount={fakeTotalCount}
updateLimitPagination={updateLimitPagination}
diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx
index e4130eee21909..f4da6983fc590 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx
+++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx
@@ -54,6 +54,7 @@ interface HostRiskScoreTableProps {
isInspect: boolean;
loading: boolean;
loadPage: (newActivePage: number) => void;
+ setQuerySkip: (skip: boolean) => void;
severityCount: SeverityCount;
totalCount: number;
type: hostsModel.HostsType;
@@ -71,6 +72,7 @@ const HostRiskScoreTableComponent: React.FC = ({
isInspect,
loading,
loadPage,
+ setQuerySkip,
severityCount,
totalCount,
type,
@@ -207,6 +209,7 @@ const HostRiskScoreTableComponent: React.FC = ({
loadPage={loadPage}
onChange={onSort}
pageOfItems={data}
+ setQuerySkip={setQuerySkip}
showMorePagesIndicator={false}
sorting={sort}
split={true}
diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap
index 59a00cbf190f6..f646fc12c4697 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap
@@ -36,6 +36,7 @@ exports[`Hosts Table rendering it renders the default Hosts table 1`] = `
isInspect={false}
loadPage={[MockFunction]}
loading={false}
+ setQuerySkip={[MockFunction]}
showMorePagesIndicator={false}
totalCount={-1}
type="page"
diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx
index 71efbb0a44d15..43dc31c68d1bc 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx
@@ -69,6 +69,7 @@ describe('Hosts Table', () => {
fakeTotalCount={0}
loading={false}
loadPage={loadPage}
+ setQuerySkip={jest.fn()}
showMorePagesIndicator={false}
totalCount={-1}
type={hostsModel.HostsType.page}
@@ -91,6 +92,7 @@ describe('Hosts Table', () => {
data={mockData}
totalCount={0}
fakeTotalCount={-1}
+ setQuerySkip={jest.fn()}
showMorePagesIndicator={false}
loadPage={loadPage}
type={hostsModel.HostsType.page}
@@ -113,6 +115,7 @@ describe('Hosts Table', () => {
data={mockData}
totalCount={0}
fakeTotalCount={-1}
+ setQuerySkip={jest.fn()}
showMorePagesIndicator={false}
loadPage={loadPage}
type={hostsModel.HostsType.page}
@@ -136,6 +139,7 @@ describe('Hosts Table', () => {
data={mockData}
totalCount={0}
fakeTotalCount={-1}
+ setQuerySkip={jest.fn()}
showMorePagesIndicator={false}
loadPage={loadPage}
type={hostsModel.HostsType.page}
diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx
index 01306004844d8..42c8254ffd183 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx
+++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx
@@ -42,6 +42,7 @@ interface HostsTableProps {
isInspect: boolean;
loading: boolean;
loadPage: (newActivePage: number) => void;
+ setQuerySkip: (skip: boolean) => void;
showMorePagesIndicator: boolean;
totalCount: number;
type: hostsModel.HostsType;
@@ -77,6 +78,7 @@ const HostsTableComponent: React.FC = ({
isInspect,
loading,
loadPage,
+ setQuerySkip,
showMorePagesIndicator,
totalCount,
type,
@@ -172,6 +174,7 @@ const HostsTableComponent: React.FC = ({
loadPage={loadPage}
onChange={onChange}
pageOfItems={data}
+ setQuerySkip={setQuerySkip}
showMorePagesIndicator={showMorePagesIndicator}
sorting={sorting}
totalCount={fakeTotalCount}
diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx
new file mode 100644
index 0000000000000..164b88399bbe9
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx
@@ -0,0 +1,66 @@
+/*
+ * 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 { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications';
+import { useQueryToggle } from '../../../../common/containers/query_toggle';
+import { render } from '@testing-library/react';
+import { TestProviders } from '../../../../common/mock';
+import React from 'react';
+import { HostsKpiAuthentications } from './index';
+
+jest.mock('../../../../common/containers/query_toggle');
+jest.mock('../../../containers/kpi_hosts/authentications');
+jest.mock('../common', () => ({
+ KpiBaseComponentManage: () => ,
+}));
+
+describe('Authentications KPI', () => {
+ const mockUseHostsKpiAuthentications = useHostsKpiAuthentications as jest.Mock;
+ const mockUseQueryToggle = useQueryToggle as jest.Mock;
+ const defaultProps = {
+ from: '2019-06-25T04:31:59.345Z',
+ to: '2019-06-25T06:31:59.345Z',
+ indexNames: [],
+ narrowDateRange: jest.fn(),
+ setQuery: jest.fn(),
+ skip: false,
+ };
+ beforeEach(() => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() });
+ mockUseHostsKpiAuthentications.mockReturnValue([
+ false,
+ {
+ id: '123',
+ inspect: {
+ dsl: [],
+ response: [],
+ },
+ refetch: jest.fn(),
+ },
+ ]);
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ it('toggleStatus=true, do not skip', () => {
+ render(
+
+
+
+ );
+ expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(false);
+ });
+ it('toggleStatus=false, skip', () => {
+ mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() });
+ render(
+
+
+
+ );
+ expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(true);
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx
index 1158c842e04cb..f12eca88ffc95 100644
--- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx
+++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx
@@ -5,17 +5,18 @@
* 2.0.
*/
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import { StatItems } from '../../../../common/components/stat_items';
import { kpiUserAuthenticationsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area';
import { kpiUserAuthenticationsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar';
import { kpiUserAuthenticationsMetricSuccessLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success';
import { kpiUserAuthenticationsMetricFailureLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure';
-import { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications';
+import { useHostsKpiAuthentications, ID } from '../../../containers/kpi_hosts/authentications';
import { KpiBaseComponentManage } from '../common';
import { HostsKpiProps, HostsKpiChartColors } from '../types';
import * as i18n from './translations';
+import { useQueryToggle } from '../../../../common/containers/query_toggle';
export const fieldsMapping: Readonly = [
{
@@ -57,12 +58,17 @@ const HostsKpiAuthenticationsComponent: React.FC = ({
setQuery,
skip,
}) => {
+ const { toggleStatus } = useQueryToggle(ID);
+ const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
+ useEffect(() => {
+ setQuerySkip(skip || !toggleStatus);
+ }, [skip, toggleStatus]);
const [loading, { refetch, id, inspect, ...data }] = useHostsKpiAuthentications({
filterQuery,
endDate: to,
indexNames,
startDate: from,
- skip,
+ skip: querySkip,
});
return (
@@ -77,6 +83,7 @@ const HostsKpiAuthenticationsComponent: React.FC