+ {i18n.translate(
+ 'xpack.enterpriseSearch.content.crawler.crawlerConfiguration.extractHTML.addExtraFieldDescription',
+ {
+ defaultMessage:
+ 'Add an extra field in all documents with the value of the full HTML of the page being crawled.',
+ }
+ )}
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.content.crawler.crawlerConfiguration.extractHTML.increasedSizeWarning',
+ {
+ defaultMessage:
+ 'This may dramatically increase the index size if the site being crawled is large.',
+ }
+ )}
+
+
+
+
+
+
+ updateHtmlExtraction(event.target.checked)}
+ />
+
+
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.content.crawler.crawlerConfiguration.extractHTML.learnMoreLink',
+ {
+ defaultMessage: 'Learn more about storing full HTML.',
+ }
+ )}
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration_logic.ts
new file mode 100644
index 0000000000000..9298ca3b32585
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration_logic.ts
@@ -0,0 +1,85 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { kea, MakeLogicType } from 'kea';
+
+import { Status } from '../../../../../../../common/types/api';
+import { Connector } from '../../../../../../../common/types/connectors';
+
+import {
+ UpdateHtmlExtractionActions,
+ UpdateHtmlExtractionApiLogic,
+} from '../../../../api/crawler/update_html_extraction_api_logic';
+import { CachedFetchIndexApiLogicActions } from '../../../../api/index/cached_fetch_index_api_logic';
+import { isCrawlerIndex } from '../../../../utils/indices';
+import { IndexViewLogic } from '../../index_view_logic';
+
+interface CrawlerConfigurationLogicActions {
+ apiError: UpdateHtmlExtractionActions['apiError'];
+ apiSuccess: UpdateHtmlExtractionActions['apiSuccess'];
+ fetchIndex: () => void;
+ fetchIndexApiSuccess: CachedFetchIndexApiLogicActions['apiSuccess'];
+ htmlExtraction: boolean;
+ makeRequest: UpdateHtmlExtractionActions['makeRequest'];
+ updateHtmlExtraction(htmlExtraction: boolean): { htmlExtraction: boolean };
+}
+
+interface CrawlerConfigurationLogicValues {
+ connector: Connector | undefined;
+ indexName: string;
+ localHtmlExtraction: boolean | null;
+ status: Status;
+}
+
+export const CrawlerConfigurationLogic = kea<
+ MakeLogicType
+>({
+ actions: {
+ updateHtmlExtraction: (htmlExtraction) => ({ htmlExtraction }),
+ },
+ connect: {
+ actions: [
+ IndexViewLogic,
+ ['fetchIndex', 'fetchIndexApiSuccess'],
+ UpdateHtmlExtractionApiLogic,
+ ['apiSuccess', 'makeRequest'],
+ ],
+ values: [IndexViewLogic, ['connector', 'indexName'], UpdateHtmlExtractionApiLogic, ['status']],
+ },
+ listeners: ({ actions, values }) => ({
+ apiSuccess: () => {
+ actions.fetchIndex();
+ },
+ updateHtmlExtraction: ({ htmlExtraction }) => {
+ actions.makeRequest({ htmlExtraction, indexName: values.indexName });
+ },
+ }),
+ path: ['enterprise_search', 'search_index', 'crawler', 'configuration'],
+ reducers: {
+ localHtmlExtraction: [
+ null,
+ {
+ apiSuccess: (_, { htmlExtraction }) => htmlExtraction,
+ fetchIndexApiSuccess: (_, index) => {
+ if (isCrawlerIndex(index)) {
+ return index.connector.configuration.extract_full_html?.value ?? null;
+ }
+ return null;
+ },
+ },
+ ],
+ },
+ selectors: ({ selectors }) => ({
+ htmlExtraction: [
+ () => [selectors.connector, selectors.localHtmlExtraction],
+ (connector: Connector | null, localHtmlExtraction: boolean | null) =>
+ localHtmlExtraction !== null
+ ? localHtmlExtraction
+ : connector?.configuration.extract_full_html?.value ?? false,
+ ],
+ }),
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts
index 26c1f823b479d..4ed5b7cef1b41 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts
@@ -70,6 +70,7 @@ export interface IndexViewValues {
hasAdvancedFilteringFeature: boolean;
hasBasicFilteringFeature: boolean;
hasFilteringFeature: boolean;
+ htmlExtraction: boolean | undefined;
index: ElasticsearchViewIndex | undefined;
indexData: typeof CachedFetchIndexApiLogic.values.indexData;
indexName: string;
@@ -214,6 +215,11 @@ export const IndexViewLogic = kea [selectors.hasAdvancedFilteringFeature, selectors.hasBasicFilteringFeature],
(advancedFeature: boolean, basicFeature: boolean) => advancedFeature || basicFeature,
],
+ htmlExtraction: [
+ () => [selectors.connector],
+ (connector: Connector | undefined) =>
+ connector?.configuration.extract_full_html?.value ?? undefined,
+ ],
index: [
() => [selectors.indexData],
(data: IndexViewValues['indexData']) => (data ? indexToViewIndex(data) : undefined),
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx
index 5c5b691d42ce1..b09a0a9d8a96a 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx
@@ -36,6 +36,7 @@ import { ConnectorSchedulingComponent } from './connector/connector_scheduling';
import { ConnectorSyncRules } from './connector/sync_rules/connector_rules';
import { AutomaticCrawlScheduler } from './crawler/automatic_crawl_scheduler/automatic_crawl_scheduler';
import { CrawlCustomSettingsFlyout } from './crawler/crawl_custom_settings_flyout/crawl_custom_settings_flyout';
+import { CrawlerConfiguration } from './crawler/crawler_configuration/crawler_configuration';
import { SearchIndexDomainManagement } from './crawler/domain_management/domain_management';
import { SearchIndexDocuments } from './documents';
import { SearchIndexIndexMappings } from './index_mappings';
@@ -56,6 +57,7 @@ export enum SearchIndexTabId {
SCHEDULING = 'scheduling',
// crawler indices
DOMAIN_MANAGEMENT = 'domain_management',
+ CRAWLER_CONFIGURATION = 'crawler_configuration',
}
export const SearchIndex: React.FC = () => {
@@ -164,6 +166,16 @@ export const SearchIndex: React.FC = () => {
defaultMessage: 'Manage Domains',
}),
},
+ {
+ content: ,
+ id: SearchIndexTabId.CRAWLER_CONFIGURATION,
+ name: i18n.translate(
+ 'xpack.enterpriseSearch.content.searchIndex.crawlerConfigurationTabLabel',
+ {
+ defaultMessage: 'Configuration',
+ }
+ ),
+ },
{
content: ,
id: SearchIndexTabId.SCHEDULING,
diff --git a/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.test.ts b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.test.ts
new file mode 100644
index 0000000000000..d859d9639b2b6
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.test.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 { IScopedClusterClient } from '@kbn/core/server';
+
+import { CONNECTORS_INDEX } from '../..';
+import { Connector } from '../../../common/types/connectors';
+
+import { updateHtmlExtraction } from './put_html_extraction';
+
+describe('updateHtmlExtraction lib function', () => {
+ const mockClient = {
+ asCurrentUser: {
+ update: jest.fn(),
+ },
+ asInternalUser: {},
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should update connector configuration', async () => {
+ mockClient.asCurrentUser.update.mockResolvedValue(true);
+ const mockConnector = {
+ configuration: { test: { label: 'haha', value: 'this' } },
+ id: 'connectorId',
+ };
+
+ await updateHtmlExtraction(
+ mockClient as unknown as IScopedClusterClient,
+ true,
+ mockConnector as any as Connector
+ );
+ expect(mockClient.asCurrentUser.update).toHaveBeenCalledWith({
+ doc: {
+ configuration: {
+ ...mockConnector.configuration,
+ extract_full_html: { label: 'Extract full HTML', value: true },
+ },
+ },
+ id: 'connectorId',
+ index: CONNECTORS_INDEX,
+ refresh: 'wait_for',
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.ts b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.ts
new file mode 100644
index 0000000000000..bf7c18a575853
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 { IScopedClusterClient } from '@kbn/core-elasticsearch-server';
+
+import { CONNECTORS_INDEX } from '../..';
+import { Connector } from '../../../common/types/connectors';
+
+export async function updateHtmlExtraction(
+ client: IScopedClusterClient,
+ htmlExtraction: boolean,
+ connector: Connector
+) {
+ return await client.asCurrentUser.update({
+ doc: {
+ configuration: {
+ ...connector.configuration,
+ extract_full_html: {
+ label: 'Extract full HTML',
+ value: htmlExtraction,
+ },
+ },
+ },
+ id: connector.id,
+ index: CONNECTORS_INDEX,
+ refresh: 'wait_for',
+ });
+}
diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts
index c3b034f0b6ce7..08cea961709fc 100644
--- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts
@@ -6,6 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
+
import { i18n } from '@kbn/i18n';
import { ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE } from '../../../../common/constants';
@@ -15,6 +16,7 @@ import { addConnector } from '../../../lib/connectors/add_connector';
import { deleteConnectorById } from '../../../lib/connectors/delete_connector';
import { fetchConnectorByIndexName } from '../../../lib/connectors/fetch_connectors';
import { fetchCrawlerByIndexName } from '../../../lib/crawler/fetch_crawlers';
+import { updateHtmlExtraction } from '../../../lib/crawler/put_html_extraction';
import { deleteIndex } from '../../../lib/indices/delete_index';
import { RouteDependencies } from '../../../plugin';
import { createError } from '../../../utils/create_error';
@@ -389,6 +391,44 @@ export function registerCrawlerRoutes(routeDependencies: RouteDependencies) {
})
);
+ router.put(
+ {
+ path: '/internal/enterprise_search/indices/{indexName}/crawler/html_extraction',
+ validate: {
+ body: schema.object({
+ extract_full_html: schema.boolean(),
+ }),
+ params: schema.object({
+ indexName: schema.string(),
+ }),
+ },
+ },
+ elasticsearchErrorHandler(log, async (context, request, response) => {
+ const { client } = (await context.core).elasticsearch;
+
+ const connector = await fetchConnectorByIndexName(client, request.params.indexName);
+ if (
+ connector &&
+ connector.service_type === ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE
+ ) {
+ await updateHtmlExtraction(client, request.body.extract_full_html, connector);
+ return response.ok();
+ } else {
+ return createError({
+ errorCode: ErrorCode.RESOURCE_NOT_FOUND,
+ message: i18n.translate(
+ 'xpack.enterpriseSearch.server.routes.updateHtmlExtraction.noCrawlerFound',
+ {
+ defaultMessage: 'Could not find a crawler for this index',
+ }
+ ),
+ response,
+ statusCode: 404,
+ });
+ }
+ })
+ );
+
registerCrawlerCrawlRulesRoutes(routeDependencies);
registerCrawlerEntryPointRoutes(routeDependencies);
registerCrawlerSitemapRoutes(routeDependencies);
From 6e36fdbe53451fa4ca8cbf86302a931f73b3d977 Mon Sep 17 00:00:00 2001
From: Kfir Peled <61654899+kfirpeled@users.noreply.github.com>
Date: Mon, 6 Feb 2023 21:44:15 +0200
Subject: [PATCH 009/134] [Cloud Security] added
`/internal/cloud_security_posture/status` basic tests (#150102)
---
.../plugins/cloud_security_posture/README.md | 5 +
.../apis/cloud_security_posture/index.ts | 1 +
.../apis/cloud_security_posture/status.ts | 125 ++++++++++++++++++
x-pack/test/tsconfig.json | 1 +
4 files changed, 132 insertions(+)
create mode 100644 x-pack/test/api_integration/apis/cloud_security_posture/status.ts
diff --git a/x-pack/plugins/cloud_security_posture/README.md b/x-pack/plugins/cloud_security_posture/README.md
index 7a4646d08ba07..a655d292c39ee 100755
--- a/x-pack/plugins/cloud_security_posture/README.md
+++ b/x-pack/plugins/cloud_security_posture/README.md
@@ -49,3 +49,8 @@ yarn test:ftr --config x-pack/test/cloud_security_posture_functional/config.ts
> **Note**
> in development, run them separately with `ftr:runner` and `ftr:server`
+
+```bash
+yarn test:ftr:server --config x-pack/test/api_integration/config.ts
+yarn test:ftr:runner --include-tag=cloud_security_posture --config x-pack/test/api_integration/config.ts
+```
\ No newline at end of file
diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts
index 8b80e0d0eab0c..60dc074044ba1 100644
--- a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts
+++ b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts
@@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('cloud_security_posture', function () {
this.tags(['cloud_security_posture']);
+ loadTestFile(require.resolve('./status'));
// Place your tests files under this directory and add the following here:
// loadTestFile(require.resolve('./your test name'));
diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/status.ts b/x-pack/test/api_integration/apis/cloud_security_posture/status.ts
new file mode 100644
index 0000000000000..6d10aa2f60f4a
--- /dev/null
+++ b/x-pack/test/api_integration/apis/cloud_security_posture/status.ts
@@ -0,0 +1,125 @@
+/*
+ * 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 expect from '@kbn/expect';
+import type { CspSetupStatus } from '@kbn/cloud-security-posture-plugin/common/types';
+import type { SuperTest, Test } from 'supertest';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function ({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+ const kibanaServer = getService('kibanaServer');
+
+ describe('GET /internal/cloud_security_posture/status', () => {
+ let agentPolicyId: string;
+
+ beforeEach(async () => {
+ await kibanaServer.savedObjects.cleanStandardList();
+ await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
+
+ const { body: agentPolicyResponse } = await supertest
+ .post(`/api/fleet/agent_policies`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ name: 'Test policy',
+ namespace: 'default',
+ });
+ agentPolicyId = agentPolicyResponse.item.id;
+ });
+
+ afterEach(async () => {
+ await kibanaServer.savedObjects.cleanStandardList();
+ await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
+ });
+
+ it(`Should return not-deployed when installed kspm`, async () => {
+ await createPackagePolicy(
+ supertest,
+ agentPolicyId,
+ 'kspm',
+ 'cloudbeat/cis_k8s',
+ 'vanilla',
+ 'kspm'
+ );
+
+ const { body: res }: { body: CspSetupStatus } = await supertest
+ .get(`/internal/cloud_security_posture/status`)
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+
+ expect(res.status).to.be('not-deployed');
+ expect(res.installedPolicyTemplates).length(1).contain('kspm');
+ expect(res.healthyAgents).to.be(0);
+ });
+
+ it(`Should return not-deployed when installed cspm`, async () => {
+ await createPackagePolicy(
+ supertest,
+ agentPolicyId,
+ 'cspm',
+ 'cloudbeat/cis_aws',
+ 'aws',
+ 'cspm'
+ );
+
+ const { body: res }: { body: CspSetupStatus } = await supertest
+ .get(`/internal/cloud_security_posture/status`)
+ .set('kbn-xsrf', 'xxxx')
+ .expect(200);
+
+ expect(res.status).to.be('not-deployed');
+ expect(res.installedPolicyTemplates).length(1).contain('cspm');
+ expect(res.healthyAgents).to.be(0);
+ });
+ });
+}
+
+async function createPackagePolicy(
+ supertest: SuperTest,
+ agentPolicyId: string,
+ policyTemplate: string,
+ input: string,
+ deployment: string,
+ posture: string
+) {
+ const { body: postPackageResponse } = await supertest
+ .post(`/api/fleet/package_policies`)
+ .set('kbn-xsrf', 'xxxx')
+ .send({
+ force: true,
+ name: 'cloud_security_posture-1',
+ description: '',
+ namespace: 'default',
+ policy_id: agentPolicyId,
+ enabled: true,
+ inputs: [
+ {
+ enabled: true,
+ type: input,
+ policy_template: policyTemplate,
+ },
+ ],
+ package: {
+ name: 'cloud_security_posture',
+ title: 'Kubernetes Security Posture Management',
+ version: '1.2.8',
+ },
+ vars: {
+ deployment: {
+ value: deployment,
+ type: 'text',
+ },
+ posture: {
+ value: posture,
+ type: 'text',
+ },
+ },
+ })
+ .expect(200);
+
+ return postPackageResponse.item;
+}
diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json
index 43f96bcd0c429..ef8c79b7b03ca 100644
--- a/x-pack/test/tsconfig.json
+++ b/x-pack/test/tsconfig.json
@@ -112,5 +112,6 @@
"@kbn/stdio-dev-helpers",
"@kbn/alerting-api-integration-helpers",
"@kbn/securitysolution-ecs",
+ "@kbn/cloud-security-posture-plugin",
]
}
From 78947c4c6f0638d25f38904d967b922aa1f99726 Mon Sep 17 00:00:00 2001
From: Jon
Date: Mon, 6 Feb 2023 13:52:01 -0600
Subject: [PATCH 010/134] Upgrade terser to 5.16.1 (#149702)
Also updates our renovate config to track this. Plan on adding a few
more build related dependencies.
---
package.json | 2 +-
renovate.json | 11 +++++++++++
yarn.lock | 8 ++++----
3 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/package.json b/package.json
index 049163e6dfd7c..c2613ebda6e76 100644
--- a/package.json
+++ b/package.json
@@ -1176,7 +1176,7 @@
"svgo": "^2.8.0",
"tape": "^5.0.1",
"tempy": "^0.3.0",
- "terser": "^5.15.1",
+ "terser": "^5.16.1",
"terser-webpack-plugin": "^4.2.3",
"tough-cookie": "^4.1.2",
"tree-kill": "^1.2.2",
diff --git a/renovate.json b/renovate.json
index 932f0a6558864..ce12b9dd5a248 100644
--- a/renovate.json
+++ b/renovate.json
@@ -153,6 +153,17 @@
"labels": ["Team:Operations", "release_note:skip", "backport:all-open"],
"enabled": true
},
+ {
+ "groupName": "minify",
+ "packageNames": [
+ "gulp-terser",
+ "terser"
+ ],
+ "reviewers": ["team:kibana-operations"],
+ "matchBaseBranches": ["main"],
+ "labels": ["Team:Operations", "release_note:skip"],
+ "enabled": true
+ },
{
"groupName": "@testing-library",
"packageNames": [
diff --git a/yarn.lock b/yarn.lock
index fd2cb119a8198..77b529bdd0bcb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -26175,10 +26175,10 @@ terser@^4.1.2, terser@^4.6.3:
source-map "~0.6.1"
source-map-support "~0.5.12"
-terser@^5.14.1, terser@^5.15.1, terser@^5.3.4, terser@^5.9.0:
- version "5.15.1"
- resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.1.tgz#8561af6e0fd6d839669c73b92bdd5777d870ed6c"
- integrity sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw==
+terser@^5.14.1, terser@^5.16.1, terser@^5.3.4, terser@^5.9.0:
+ version "5.16.1"
+ resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.1.tgz#5af3bc3d0f24241c7fb2024199d5c461a1075880"
+ integrity sha512-xvQfyfA1ayT0qdK47zskQgRZeWLoOQ8JQ6mIgRGVNwZKdQMU+5FkCBjmv4QjcrTzyZquRw2FVtlJSRUmMKQslw==
dependencies:
"@jridgewell/source-map" "^0.3.2"
acorn "^8.5.0"
From ebc1bc5242a317ad1b3fa92a80373590580ee3b2 Mon Sep 17 00:00:00 2001
From: Drew Tate
Date: Mon, 6 Feb 2023 13:53:56 -0600
Subject: [PATCH 011/134] [Lens] suppress missing index toasts (#150345)
---
x-pack/plugins/lens/public/data_views_service/loader.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/x-pack/plugins/lens/public/data_views_service/loader.ts b/x-pack/plugins/lens/public/data_views_service/loader.ts
index abd8a48815122..bb27eec2076e3 100644
--- a/x-pack/plugins/lens/public/data_views_service/loader.ts
+++ b/x-pack/plugins/lens/public/data_views_service/loader.ts
@@ -176,7 +176,9 @@ export async function loadIndexPatterns({
}
indexPatterns.push(
...(await Promise.all(
- Object.values(adHocDataViews || {}).map((spec) => dataViews.create(spec))
+ Object.values(adHocDataViews || {}).map((spec) =>
+ dataViews.create({ ...spec, allowNoIndex: true })
+ )
))
);
From 148a49adb8378afc03f3b8d35fad4393a41def87 Mon Sep 17 00:00:00 2001
From: Dima Arnautov
Date: Mon, 6 Feb 2023 21:14:51 +0100
Subject: [PATCH 012/134] [Console] Replace global `GET /_mapping` request with
`GET /_mapping` (#147770)
## Summary
### Notes for reviewers
- Currently, autocomplete suggestions for fields don't work with
wildcards and data streams due to the
[bug](https://github.com/elastic/kibana/issues/149496) in the `main`. It
should be addressed separately.
### How to test
In order to spot the loading behaviour, ideally you should create an
index with a heavy mappings definition.
Afterwards, write a query that requires a field from this index, e.g.:
```
GET /_search
{
"aggs": {
"my_agg": {
"terms": {
"field": "",
"size": 10
}
}
}
}
```
Place a cursor next to the `field` property, it should trigger mappings
fetch. After that, the mappings definition for this index will be cached
and accessed synchronously.
You can also open the browser's dev tools and enable Network throttling.
It allows noticing loading behaviour for any index.
--------------------
Resolves https://github.com/elastic/kibana/issues/146855
Instead of fetching all mappings upfront, requests mapping definition on
demand per index according to the cursor position.
Considering there is a maximum response size limit of 10MB in the
`/autocomplete_entities` endpoint, field autocompletion wasn't working
at all if the overall mappings definition exceeded this size. Retrieving
mappings per index tackles this and improves the init time.
![Jan-25-2023
17-16-31](https://user-images.githubusercontent.com/5236598/214616790-4954d005-e56f-49f9-be6d-435c076270a8.gif)
### Checklist
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
---------
Co-authored-by: Jean-Louis Leysens
---
.../application/containers/editor/editor.tsx | 15 +-
.../console/public/application/index.tsx | 2 +
.../legacy_core_editor/legacy_core_editor.ts | 30 +-
.../models/sense_editor/integration.test.js | 15 +-
.../public/lib/autocomplete/autocomplete.ts | 175 ++++++----
.../console/public/lib/autocomplete/types.ts | 9 +
.../autocomplete_entities.test.js | 318 +++++++++++-------
.../lib/autocomplete_entities/mapping.ts | 121 ++++++-
.../console/public/services/autocomplete.ts | 7 +-
.../console/public/services/settings.ts | 7 +-
.../console/public/types/core_editor.ts | 7 +-
.../console/autocomplete_entities/index.ts | 36 +-
.../validation_config.ts | 33 ++
src/plugins/console/tsconfig.json | 1 +
.../apis/console/autocomplete_entities.ts | 12 +-
15 files changed, 554 insertions(+), 234 deletions(-)
create mode 100644 src/plugins/console/server/routes/api/console/autocomplete_entities/validation_config.ts
diff --git a/src/plugins/console/public/application/containers/editor/editor.tsx b/src/plugins/console/public/application/containers/editor/editor.tsx
index 93c11aa53c602..788039a2dc606 100644
--- a/src/plugins/console/public/application/containers/editor/editor.tsx
+++ b/src/plugins/console/public/application/containers/editor/editor.tsx
@@ -6,14 +6,14 @@
* Side Public License, v 1.
*/
-import React, { useCallback, memo } from 'react';
+import React, { useCallback, memo, useEffect, useState } from 'react';
import { debounce } from 'lodash';
import { EuiProgress } from '@elastic/eui';
import { EditorContentSpinner } from '../../components';
import { Panel, PanelsContainer } from '..';
import { Editor as EditorUI, EditorOutput } from './legacy/console_editor';
-import { StorageKeys } from '../../../services';
+import { getAutocompleteInfo, StorageKeys } from '../../../services';
import { useEditorReadContext, useServicesContext, useRequestReadContext } from '../../contexts';
import type { SenseEditor } from '../../models';
@@ -33,6 +33,15 @@ export const Editor = memo(({ loading, setEditorInstance }: Props) => {
const { currentTextObject } = useEditorReadContext();
const { requestInFlight } = useRequestReadContext();
+ const [fetchingMappings, setFetchingMappings] = useState(false);
+
+ useEffect(() => {
+ const subscription = getAutocompleteInfo().mapping.isLoading$.subscribe(setFetchingMappings);
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, []);
+
const [firstPanelWidth, secondPanelWidth] = storage.get(StorageKeys.WIDTH, [
INITIAL_PANEL_WIDTH,
INITIAL_PANEL_WIDTH,
@@ -50,7 +59,7 @@ export const Editor = memo(({ loading, setEditorInstance }: Props) => {
return (
<>
- {requestInFlight ? (
+ {requestInFlight || fetchingMappings ? (
diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx
index 1cf9a54210973..92d875b9db2a9 100644
--- a/src/plugins/console/public/application/index.tsx
+++ b/src/plugins/console/public/application/index.tsx
@@ -69,6 +69,8 @@ export function renderApp({
const api = createApi({ http });
const esHostService = createEsHostService({ api });
+ autocompleteInfo.mapping.setup(http, settings);
+
render(
diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts
index 5def8a696df2f..5c041a95e216f 100644
--- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts
+++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import ace from 'brace';
+import ace, { type Annotation } from 'brace';
import { Editor as IAceEditor, IEditSession as IAceEditSession } from 'brace';
import $ from 'jquery';
import {
@@ -402,8 +402,7 @@ export class LegacyCoreEditor implements CoreEditor {
getCompletions: (
// eslint-disable-next-line @typescript-eslint/naming-convention
DO_NOT_USE_1: IAceEditor,
- // eslint-disable-next-line @typescript-eslint/naming-convention
- DO_NOT_USE_2: IAceEditSession,
+ aceEditSession: IAceEditSession,
pos: { row: number; column: number },
prefix: string,
callback: (...args: unknown[]) => void
@@ -412,7 +411,30 @@ export class LegacyCoreEditor implements CoreEditor {
lineNumber: pos.row + 1,
column: pos.column + 1,
};
- autocompleter(position, prefix, callback);
+
+ const getAnnotationControls = () => {
+ let customAnnotation: Annotation;
+ return {
+ setAnnotation(text: string) {
+ const annotations = aceEditSession.getAnnotations();
+ customAnnotation = {
+ text,
+ row: pos.row,
+ column: pos.column,
+ type: 'warning',
+ };
+
+ aceEditSession.setAnnotations([...annotations, customAnnotation]);
+ },
+ removeAnnotation() {
+ aceEditSession.setAnnotations(
+ aceEditSession.getAnnotations().filter((a: Annotation) => a !== customAnnotation)
+ );
+ },
+ };
+ };
+
+ autocompleter(position, prefix, callback, getAnnotationControls());
},
},
]);
diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js
index 434deb5456caf..08912871052ba 100644
--- a/src/plugins/console/public/application/models/sense_editor/integration.test.js
+++ b/src/plugins/console/public/application/models/sense_editor/integration.test.js
@@ -13,6 +13,9 @@ import $ from 'jquery';
import * as kb from '../../../lib/kb/kb';
import { AutocompleteInfo, setAutocompleteInfo } from '../../../services';
+import { httpServiceMock } from '@kbn/core-http-browser-mocks';
+import { StorageMock } from '../../../services/storage.mock';
+import { SettingsMock } from '../../../services/settings.mock';
describe('Integration', () => {
let senseEditor;
@@ -27,6 +30,15 @@ describe('Integration', () => {
$(senseEditor.getCoreEditor().getContainer()).show();
senseEditor.autocomplete._test.removeChangeListener();
autocompleteInfo = new AutocompleteInfo();
+
+ const httpMock = httpServiceMock.createSetupContract();
+ const storage = new StorageMock({}, 'test');
+ const settingsMock = new SettingsMock(storage);
+
+ settingsMock.getAutocomplete.mockReturnValue({ fields: true });
+
+ autocompleteInfo.mapping.setup(httpMock, settingsMock);
+
setAutocompleteInfo(autocompleteInfo);
});
afterEach(() => {
@@ -164,7 +176,8 @@ describe('Integration', () => {
ac('textBoxPosition', posCompare);
ac('rangeToReplace', rangeCompare);
done();
- }
+ },
+ { setAnnotation: () => {}, removeAnnotation: () => {} }
);
});
});
diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts
index 4e3770779f580..b24767b361d7e 100644
--- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts
+++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts
@@ -593,7 +593,10 @@ export default function ({
return null;
}
- if (!context.autoCompleteSet) {
+ const isMappingsFetchingInProgress =
+ context.autoCompleteType === 'body' && !!context.asyncResultsState?.isLoading;
+
+ if (!context.autoCompleteSet && !isMappingsFetchingInProgress) {
return null; // nothing to do..
}
@@ -1123,80 +1126,112 @@ export default function ({
}
}
+ /**
+ * Extracts terms from the autocomplete set.
+ * @param context
+ */
+ function getTerms(context: AutoCompleteContext, autoCompleteSet: ResultTerm[]) {
+ const terms = _.map(
+ autoCompleteSet.filter((term) => Boolean(term) && term.name != null),
+ function (term) {
+ if (typeof term !== 'object') {
+ term = {
+ name: term,
+ };
+ } else {
+ term = _.clone(term);
+ }
+ const defaults: {
+ value?: string;
+ meta: string;
+ score: number;
+ context: AutoCompleteContext;
+ completer?: { insertMatch: (v: unknown) => void };
+ } = {
+ value: term.name,
+ meta: 'API',
+ score: 0,
+ context,
+ };
+ // we only need our custom insertMatch behavior for the body
+ if (context.autoCompleteType === 'body') {
+ defaults.completer = {
+ insertMatch() {
+ return applyTerm(term);
+ },
+ };
+ }
+ return _.defaults(term, defaults);
+ }
+ );
+
+ terms.sort(function (
+ t1: { score: number; name?: string },
+ t2: { score: number; name?: string }
+ ) {
+ /* score sorts from high to low */
+ if (t1.score > t2.score) {
+ return -1;
+ }
+ if (t1.score < t2.score) {
+ return 1;
+ }
+ /* names sort from low to high */
+ if (t1.name! < t2.name!) {
+ return -1;
+ }
+ if (t1.name === t2.name) {
+ return 0;
+ }
+ return 1;
+ });
+
+ return terms;
+ }
+
+ function getSuggestions(terms: ResultTerm[]) {
+ return _.map(terms, function (t, i) {
+ t.insertValue = t.insertValue || t.value;
+ t.value = '' + t.value; // normalize to strings
+ t.score = -i;
+ return t;
+ });
+ }
+
function getCompletions(
position: Position,
prefix: string,
- callback: (e: Error | null, result: ResultTerm[] | null) => void
+ callback: (e: Error | null, result: ResultTerm[] | null) => void,
+ annotationControls: {
+ setAnnotation: (text: string) => void;
+ removeAnnotation: () => void;
+ }
) {
try {
const context = getAutoCompleteContext(editor, position);
+
if (!context) {
callback(null, []);
} else {
- const terms = _.map(
- context.autoCompleteSet!.filter((term) => Boolean(term) && term.name != null),
- function (term) {
- if (typeof term !== 'object') {
- term = {
- name: term,
- };
- } else {
- term = _.clone(term);
- }
- const defaults: {
- value?: string;
- meta: string;
- score: number;
- context: AutoCompleteContext;
- completer?: { insertMatch: (v: unknown) => void };
- } = {
- value: term.name,
- meta: 'API',
- score: 0,
- context,
- };
- // we only need our custom insertMatch behavior for the body
- if (context.autoCompleteType === 'body') {
- defaults.completer = {
- insertMatch() {
- return applyTerm(term);
- },
- };
- }
- return _.defaults(term, defaults);
- }
- );
-
- terms.sort(function (
- t1: { score: number; name?: string },
- t2: { score: number; name?: string }
- ) {
- /* score sorts from high to low */
- if (t1.score > t2.score) {
- return -1;
- }
- if (t1.score < t2.score) {
- return 1;
- }
- /* names sort from low to high */
- if (t1.name! < t2.name!) {
- return -1;
- }
- if (t1.name === t2.name) {
- return 0;
- }
- return 1;
- });
-
- callback(
- null,
- _.map(terms, function (t, i) {
- t.insertValue = t.insertValue || t.value;
- t.value = '' + t.value; // normalize to strings
- t.score = -i;
- return t;
- })
- );
+ if (!context.asyncResultsState?.isLoading) {
+ const terms = getTerms(context, context.autoCompleteSet!);
+ const suggestions = getSuggestions(terms);
+ callback(null, suggestions);
+ }
+
+ if (context.asyncResultsState) {
+ annotationControls.setAnnotation(
+ i18n.translate('console.autocomplete.fieldsFetchingAnnotation', {
+ defaultMessage: 'Fields fetching is in progress',
+ })
+ );
+
+ context.asyncResultsState.results.then((r) => {
+ const asyncSuggestions = getSuggestions(getTerms(context, r));
+ callback(null, asyncSuggestions);
+ annotationControls.removeAnnotation();
+ });
+ }
}
} catch (e) {
// eslint-disable-next-line no-console
@@ -1216,8 +1251,12 @@ export default function ({
_editSession: unknown,
pos: Position,
prefix: string,
- callback: (e: Error | null, result: ResultTerm[] | null) => void
- ) => getCompletions(pos, prefix, callback),
+ callback: (e: Error | null, result: ResultTerm[] | null) => void,
+ annotationControls: {
+ setAnnotation: (text: string) => void;
+ removeAnnotation: () => void;
+ }
+ ) => getCompletions(pos, prefix, callback, annotationControls),
addReplacementInfoToContext,
addChangeListener: () => editor.on('changeSelection', editorChangeListener),
removeChangeListener: () => editor.off('changeSelection', editorChangeListener),
diff --git a/src/plugins/console/public/lib/autocomplete/types.ts b/src/plugins/console/public/lib/autocomplete/types.ts
index 15d32e6426a6c..a151c13f46c20 100644
--- a/src/plugins/console/public/lib/autocomplete/types.ts
+++ b/src/plugins/console/public/lib/autocomplete/types.ts
@@ -13,6 +13,7 @@ export interface ResultTerm {
insertValue?: string;
name?: string;
value?: string;
+ score?: number;
}
export interface DataAutoCompleteRulesOneOf {
@@ -25,6 +26,14 @@ export interface DataAutoCompleteRulesOneOf {
export interface AutoCompleteContext {
autoCompleteSet?: null | ResultTerm[];
+ /**
+ * Stores a state for async results, e.g. fields suggestions based on the mappings definition.
+ */
+ asyncResultsState?: {
+ isLoading: boolean;
+ lastFetched: number | null;
+ results: Promise;
+ };
endpoint?: null | {
paramsAutocomplete: {
getTopLevelComponents: (method?: string | null) => unknown;
diff --git a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js
index 5349538799d9b..f856ef5750a17 100644
--- a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js
+++ b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js
@@ -9,6 +9,9 @@
import '../../application/models/sense_editor/sense_editor.test.mocks';
import { setAutocompleteInfo, AutocompleteInfo } from '../../services';
import { expandAliases } from './expand_aliases';
+import { httpServiceMock } from '@kbn/core-http-browser-mocks';
+import { SettingsMock } from '../../services/settings.mock';
+import { StorageMock } from '../../services/storage.mock';
function fc(f1, f2) {
if (f1.name < f2.name) {
@@ -32,10 +35,20 @@ describe('Autocomplete entities', () => {
let componentTemplate;
let dataStream;
let autocompleteInfo;
+ let settingsMock;
+ let httpMock;
+
beforeEach(() => {
autocompleteInfo = new AutocompleteInfo();
setAutocompleteInfo(autocompleteInfo);
mapping = autocompleteInfo.mapping;
+
+ httpMock = httpServiceMock.createSetupContract();
+ const storage = new StorageMock({}, 'test');
+ settingsMock = new SettingsMock(storage);
+
+ mapping.setup(httpMock, settingsMock);
+
alias = autocompleteInfo.alias;
legacyTemplate = autocompleteInfo.legacyTemplate;
indexTemplate = autocompleteInfo.indexTemplate;
@@ -48,61 +61,98 @@ describe('Autocomplete entities', () => {
});
describe('Mappings', function () {
- test('Multi fields 1.0 style', function () {
- mapping.loadMappings({
- index: {
- properties: {
- first_name: {
- type: 'string',
- index: 'analyzed',
- path: 'just_name',
- fields: {
- any_name: { type: 'string', index: 'analyzed' },
- },
- },
- last_name: {
- type: 'string',
- index: 'no',
- fields: {
- raw: { type: 'string', index: 'analyzed' },
+ describe('When fields autocomplete is disabled', () => {
+ beforeEach(() => {
+ settingsMock.getAutocomplete.mockReturnValue({ fields: false });
+ });
+
+ test('does not return any suggestions', function () {
+ mapping.loadMappings({
+ index: {
+ properties: {
+ first_name: {
+ type: 'string',
+ index: 'analyzed',
+ path: 'just_name',
+ fields: {
+ any_name: { type: 'string', index: 'analyzed' },
+ },
},
},
},
- },
- });
+ });
- expect(mapping.getMappings('index').sort(fc)).toEqual([
- f('any_name', 'string'),
- f('first_name', 'string'),
- f('last_name', 'string'),
- f('last_name.raw', 'string'),
- ]);
+ expect(mapping.getMappings('index').sort(fc)).toEqual([]);
+ });
});
- test('Simple fields', function () {
- mapping.loadMappings({
- index: {
- properties: {
- str: {
- type: 'string',
- },
- number: {
- type: 'int',
+ describe('When fields autocomplete is enabled', () => {
+ beforeEach(() => {
+ settingsMock.getAutocomplete.mockReturnValue({ fields: true });
+ httpMock.get.mockReturnValue(
+ Promise.resolve({
+ mappings: { index: { mappings: { properties: { '@timestamp': { type: 'date' } } } } },
+ })
+ );
+ });
+
+ test('attempts to fetch mappings if not loaded', async () => {
+ const autoCompleteContext = {};
+ let loadingIndicator;
+
+ mapping.isLoading$.subscribe((v) => {
+ loadingIndicator = v;
+ });
+
+ // act
+ mapping.getMappings('index', [], autoCompleteContext);
+
+ expect(autoCompleteContext.asyncResultsState.isLoading).toBe(true);
+ expect(loadingIndicator).toBe(true);
+
+ expect(httpMock.get).toHaveBeenCalled();
+
+ const fields = await autoCompleteContext.asyncResultsState.results;
+
+ expect(loadingIndicator).toBe(false);
+ expect(autoCompleteContext.asyncResultsState.isLoading).toBe(false);
+ expect(fields).toEqual([{ name: '@timestamp', type: 'date' }]);
+ });
+
+ test('Multi fields 1.0 style', function () {
+ mapping.loadMappings({
+ index: {
+ properties: {
+ first_name: {
+ type: 'string',
+ index: 'analyzed',
+ path: 'just_name',
+ fields: {
+ any_name: { type: 'string', index: 'analyzed' },
+ },
+ },
+ last_name: {
+ type: 'string',
+ index: 'no',
+ fields: {
+ raw: { type: 'string', index: 'analyzed' },
+ },
+ },
},
},
- },
- });
+ });
- expect(mapping.getMappings('index').sort(fc)).toEqual([
- f('number', 'int'),
- f('str', 'string'),
- ]);
- });
+ expect(mapping.getMappings('index').sort(fc)).toEqual([
+ f('any_name', 'string'),
+ f('first_name', 'string'),
+ f('last_name', 'string'),
+ f('last_name.raw', 'string'),
+ ]);
+ });
- test('Simple fields - 1.0 style', function () {
- mapping.loadMappings({
- index: {
- mappings: {
+ test('Simple fields', function () {
+ mapping.loadMappings({
+ index: {
properties: {
str: {
type: 'string',
@@ -112,108 +162,130 @@ describe('Autocomplete entities', () => {
},
},
},
- },
- });
+ });
- expect(mapping.getMappings('index').sort(fc)).toEqual([
- f('number', 'int'),
- f('str', 'string'),
- ]);
- });
+ expect(mapping.getMappings('index').sort(fc)).toEqual([
+ f('number', 'int'),
+ f('str', 'string'),
+ ]);
+ });
- test('Nested fields', function () {
- mapping.loadMappings({
- index: {
- properties: {
- person: {
- type: 'object',
+ test('Simple fields - 1.0 style', function () {
+ mapping.loadMappings({
+ index: {
+ mappings: {
properties: {
- name: {
- properties: {
- first_name: { type: 'string' },
- last_name: { type: 'string' },
- },
+ str: {
+ type: 'string',
+ },
+ number: {
+ type: 'int',
},
- sid: { type: 'string', index: 'not_analyzed' },
},
},
- message: { type: 'string' },
},
- },
- });
+ });
- expect(mapping.getMappings('index', []).sort(fc)).toEqual([
- f('message'),
- f('person.name.first_name'),
- f('person.name.last_name'),
- f('person.sid'),
- ]);
- });
+ expect(mapping.getMappings('index').sort(fc)).toEqual([
+ f('number', 'int'),
+ f('str', 'string'),
+ ]);
+ });
- test('Enabled fields', function () {
- mapping.loadMappings({
- index: {
- properties: {
- person: {
- type: 'object',
- properties: {
- name: {
- type: 'object',
- enabled: false,
+ test('Nested fields', function () {
+ mapping.loadMappings({
+ index: {
+ properties: {
+ person: {
+ type: 'object',
+ properties: {
+ name: {
+ properties: {
+ first_name: { type: 'string' },
+ last_name: { type: 'string' },
+ },
+ },
+ sid: { type: 'string', index: 'not_analyzed' },
},
- sid: { type: 'string', index: 'not_analyzed' },
},
+ message: { type: 'string' },
},
- message: { type: 'string' },
},
- },
- });
+ });
- expect(mapping.getMappings('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]);
- });
+ expect(mapping.getMappings('index', []).sort(fc)).toEqual([
+ f('message'),
+ f('person.name.first_name'),
+ f('person.name.last_name'),
+ f('person.sid'),
+ ]);
+ });
- test('Path tests', function () {
- mapping.loadMappings({
- index: {
- properties: {
- name1: {
- type: 'object',
- path: 'just_name',
- properties: {
- first1: { type: 'string' },
- last1: { type: 'string', index_name: 'i_last_1' },
+ test('Enabled fields', function () {
+ mapping.loadMappings({
+ index: {
+ properties: {
+ person: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'object',
+ enabled: false,
+ },
+ sid: { type: 'string', index: 'not_analyzed' },
+ },
},
+ message: { type: 'string' },
},
- name2: {
- type: 'object',
- path: 'full',
- properties: {
- first2: { type: 'string' },
- last2: { type: 'string', index_name: 'i_last_2' },
+ },
+ });
+
+ expect(mapping.getMappings('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]);
+ });
+
+ test('Path tests', function () {
+ mapping.loadMappings({
+ index: {
+ properties: {
+ name1: {
+ type: 'object',
+ path: 'just_name',
+ properties: {
+ first1: { type: 'string' },
+ last1: { type: 'string', index_name: 'i_last_1' },
+ },
+ },
+ name2: {
+ type: 'object',
+ path: 'full',
+ properties: {
+ first2: { type: 'string' },
+ last2: { type: 'string', index_name: 'i_last_2' },
+ },
},
},
},
- },
- });
+ });
- expect(mapping.getMappings().sort(fc)).toEqual([
- f('first1'),
- f('i_last_1'),
- f('name2.first2'),
- f('name2.i_last_2'),
- ]);
- });
+ expect(mapping.getMappings().sort(fc)).toEqual([
+ f('first1'),
+ f('i_last_1'),
+ f('name2.first2'),
+ f('name2.i_last_2'),
+ ]);
+ });
- test('Use index_name tests', function () {
- mapping.loadMappings({
- index: {
- properties: {
- last1: { type: 'string', index_name: 'i_last_1' },
+ test('Use index_name tests', function () {
+ mapping.loadMappings({
+ index: {
+ properties: {
+ last1: { type: 'string', index_name: 'i_last_1' },
+ },
},
- },
- });
+ });
- expect(mapping.getMappings().sort(fc)).toEqual([f('i_last_1')]);
+ expect(mapping.getMappings().sort(fc)).toEqual([f('i_last_1')]);
+ });
});
});
diff --git a/src/plugins/console/public/lib/autocomplete_entities/mapping.ts b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts
index 71e72dac0a280..3c383ca124167 100644
--- a/src/plugins/console/public/lib/autocomplete_entities/mapping.ts
+++ b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts
@@ -7,9 +7,15 @@
*/
import _ from 'lodash';
+import { BehaviorSubject } from 'rxjs';
import type { IndicesGetMappingResponse } from '@elastic/elasticsearch/lib/api/types';
+import { HttpSetup } from '@kbn/core-http-browser';
+import { type Settings } from '../../services';
+import { API_BASE_PATH } from '../../../common/constants';
+import type { ResultTerm, AutoCompleteContext } from '../autocomplete/types';
import { expandAliases } from './expand_aliases';
import type { Field, FieldMapping } from './types';
+import { type AutoCompleteEntitiesApiResponse } from './types';
function getFieldNamesFromProperties(properties: Record = {}) {
const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => {
@@ -70,22 +76,127 @@ function getFieldNamesFromFieldMapping(
export interface BaseMapping {
perIndexTypes: Record;
- getMappings(indices: string | string[], types?: string | string[]): Field[];
+ /**
+ * Fetches mappings definition
+ */
+ fetchMappings(index: string): Promise;
+
+ /**
+ * Retrieves mappings definition from cache, fetches if necessary.
+ */
+ getMappings(
+ indices: string | string[],
+ types?: string | string[],
+ autoCompleteContext?: AutoCompleteContext
+ ): Field[];
+
+ /**
+ * Stores mappings definition
+ * @param mappings
+ */
loadMappings(mappings: IndicesGetMappingResponse): void;
clearMappings(): void;
}
export class Mapping implements BaseMapping {
+ private http!: HttpSetup;
+
+ private settings!: Settings;
+
+ /**
+ * Map of the mappings of actual ES indices.
+ */
public perIndexTypes: Record = {};
- getMappings = (indices: string | string[], types?: string | string[]) => {
+ private readonly _isLoading$ = new BehaviorSubject(false);
+
+ /**
+ * Indicates if mapping fetching is in progress.
+ */
+ public readonly isLoading$ = this._isLoading$.asObservable();
+
+ /**
+ * Map of the currently loading mappings for index patterns specified by a user.
+ * @private
+ */
+ private loadingState: Record = {};
+
+ public setup(http: HttpSetup, settings: Settings) {
+ this.http = http;
+ this.settings = settings;
+ }
+
+ /**
+ * Fetches mappings of the requested indices.
+ * @param index
+ */
+ async fetchMappings(index: string): Promise {
+ const response = await this.http.get(
+ `${API_BASE_PATH}/autocomplete_entities`,
+ {
+ query: { fields: true, fieldsIndices: index },
+ }
+ );
+
+ return response.mappings;
+ }
+
+ getMappings = (
+ indices: string | string[],
+ types?: string | string[],
+ autoCompleteContext?: AutoCompleteContext
+ ) => {
// get fields for indices and types. Both can be a list, a string or null (meaning all).
let ret: Field[] = [];
+
+ if (!this.settings.getAutocomplete().fields) return ret;
+
indices = expandAliases(indices);
if (typeof indices === 'string') {
const typeDict = this.perIndexTypes[indices] as Record;
- if (!typeDict) {
+
+ if (!typeDict || Object.keys(typeDict).length === 0) {
+ if (!autoCompleteContext) return ret;
+
+ // Mappings fetching for the index is already in progress
+ if (this.loadingState[indices]) return ret;
+
+ this.loadingState[indices] = true;
+
+ if (!autoCompleteContext.asyncResultsState) {
+ autoCompleteContext.asyncResultsState = {} as AutoCompleteContext['asyncResultsState'];
+ }
+
+ autoCompleteContext.asyncResultsState!.isLoading = true;
+
+ autoCompleteContext.asyncResultsState!.results = new Promise(
+ (resolve, reject) => {
+ this._isLoading$.next(true);
+
+ this.fetchMappings(indices as string)
+ .then((mapping) => {
+ this._isLoading$.next(false);
+
+ autoCompleteContext.asyncResultsState!.isLoading = false;
+ autoCompleteContext.asyncResultsState!.lastFetched = Date.now();
+
+ // cache mappings
+ this.loadMappings(mapping);
+
+ const mappings = this.getMappings(indices, types, autoCompleteContext);
+ delete this.loadingState[indices as string];
+ resolve(mappings);
+ })
+ .catch((error) => {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ this._isLoading$.next(false);
+ delete this.loadingState[indices as string];
+ });
+ }
+ );
+
return [];
}
@@ -108,7 +219,7 @@ export class Mapping implements BaseMapping {
// multi index mode.
Object.keys(this.perIndexTypes).forEach((index) => {
if (!indices || indices.length === 0 || indices.includes(index)) {
- ret.push(this.getMappings(index, types) as unknown as Field);
+ ret.push(this.getMappings(index, types, autoCompleteContext) as unknown as Field);
}
});
@@ -121,8 +232,6 @@ export class Mapping implements BaseMapping {
};
loadMappings = (mappings: IndicesGetMappingResponse) => {
- this.perIndexTypes = {};
-
Object.entries(mappings).forEach(([index, indexMapping]) => {
const normalizedIndexMappings: Record = {};
let transformedMapping: Record = indexMapping;
diff --git a/src/plugins/console/public/services/autocomplete.ts b/src/plugins/console/public/services/autocomplete.ts
index 3e1a38a514607..57324049bab94 100644
--- a/src/plugins/console/public/services/autocomplete.ts
+++ b/src/plugins/console/public/services/autocomplete.ts
@@ -53,7 +53,11 @@ export class AutocompleteInfo {
const collaborator = this.mapping;
return () => this.alias.getIndices(includeAliases, collaborator);
case ENTITIES.FIELDS:
- return this.mapping.getMappings(context.indices, context.types);
+ return this.mapping.getMappings(
+ context.indices,
+ context.types,
+ Object.getPrototypeOf(context)
+ );
case ENTITIES.INDEX_TEMPLATES:
return () => this.indexTemplate.getTemplates();
case ENTITIES.COMPONENT_TEMPLATES:
@@ -93,7 +97,6 @@ export class AutocompleteInfo {
}
private load(data: AutoCompleteEntitiesApiResponse) {
- this.mapping.loadMappings(data.mappings);
const collaborator = this.mapping;
this.alias.loadAliases(data.aliases, collaborator);
this.indexTemplate.loadTemplates(data.indexTemplates);
diff --git a/src/plugins/console/public/services/settings.ts b/src/plugins/console/public/services/settings.ts
index e4731dd3f3a31..4056d20063a3e 100644
--- a/src/plugins/console/public/services/settings.ts
+++ b/src/plugins/console/public/services/settings.ts
@@ -14,7 +14,12 @@ export const DEFAULT_SETTINGS = Object.freeze({
pollInterval: 60000,
tripleQuotes: true,
wrapMode: true,
- autocomplete: Object.freeze({ fields: true, indices: true, templates: true, dataStreams: true }),
+ autocomplete: Object.freeze({
+ fields: true,
+ indices: true,
+ templates: true,
+ dataStreams: true,
+ }),
isHistoryEnabled: true,
isKeyboardShortcutsEnabled: true,
});
diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts
index 1c9d6352914a2..34f44578a0bce 100644
--- a/src/plugins/console/public/types/core_editor.ts
+++ b/src/plugins/console/public/types/core_editor.ts
@@ -7,6 +7,7 @@
*/
import type { Editor } from 'brace';
+import { ResultTerm } from '../lib/autocomplete/types';
import { TokensProvider } from './tokens_provider';
import { Token } from './token';
@@ -23,7 +24,11 @@ export type EditorEvent =
export type AutoCompleterFunction = (
pos: Position,
prefix: string,
- callback: (...args: unknown[]) => void
+ callback: (e: Error | null, result: ResultTerm[] | null) => void,
+ annotationControls: {
+ setAnnotation: (text: string) => void;
+ removeAnnotation: () => void;
+ }
) => void;
export interface Position {
diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts
index 8bd5f8ee50b48..2d19de0a56e74 100644
--- a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts
+++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts
@@ -6,26 +6,25 @@
* Side Public License, v 1.
*/
-import { parse } from 'query-string';
import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server';
import type { RouteDependencies } from '../../..';
-interface SettingsToRetrieve {
- indices: boolean;
- fields: boolean;
- templates: boolean;
- dataStreams: boolean;
-}
+import { autoCompleteEntitiesValidationConfig, type SettingsToRetrieve } from './validation_config';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
// Limit the response size to 10MB, because the response can be very large and sending it to the client
// can cause the browser to hang.
const getMappings = async (settings: SettingsToRetrieve, esClient: IScopedClusterClient) => {
- if (settings.fields) {
- const mappings = await esClient.asInternalUser.indices.getMapping(undefined, {
- maxResponseSize: MAX_RESPONSE_SIZE,
- maxCompressedResponseSize: MAX_RESPONSE_SIZE,
- });
+ if (settings.fields && settings.fieldsIndices) {
+ const mappings = await esClient.asInternalUser.indices.getMapping(
+ {
+ index: settings.fieldsIndices,
+ },
+ {
+ maxResponseSize: MAX_RESPONSE_SIZE,
+ maxCompressedResponseSize: MAX_RESPONSE_SIZE,
+ }
+ );
return mappings;
}
// If the user doesn't want autocomplete suggestions, then clear any that exist.
@@ -87,20 +86,11 @@ export const registerAutocompleteEntitiesRoute = (deps: RouteDependencies) => {
options: {
tags: ['access:console'],
},
- validate: false,
+ validate: autoCompleteEntitiesValidationConfig,
},
async (context, request, response) => {
const esClient = (await context.core).elasticsearch.client;
- const settings = parse(request.url.search, {
- parseBooleans: true,
- }) as unknown as SettingsToRetrieve;
-
- // If no settings are specified, then return 400.
- if (Object.keys(settings).length === 0) {
- return response.badRequest({
- body: 'Request must contain at least one of the following parameters: indices, fields, templates, dataStreams',
- });
- }
+ const settings = request.query;
// Wait for all requests to complete, in case one of them fails return the successfull ones
const results = await Promise.allSettled([
diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/validation_config.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/validation_config.ts
new file mode 100644
index 0000000000000..48bab100d0b61
--- /dev/null
+++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/validation_config.ts
@@ -0,0 +1,33 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { schema, TypeOf } from '@kbn/config-schema';
+
+export const autoCompleteEntitiesValidationConfig = {
+ query: schema.object(
+ {
+ indices: schema.maybe(schema.boolean()),
+ fields: schema.maybe(schema.boolean()),
+ templates: schema.maybe(schema.boolean()),
+ dataStreams: schema.maybe(schema.boolean()),
+ /**
+ * Comma separated list of indices for mappings retrieval.
+ */
+ fieldsIndices: schema.maybe(schema.string()),
+ },
+ {
+ validate: (payload) => {
+ if (Object.keys(payload).length === 0) {
+ return 'The request must contain at least one of the following parameters: indices, fields, templates, dataStreams.';
+ }
+ },
+ }
+ ),
+};
+
+export type SettingsToRetrieve = TypeOf;
diff --git a/src/plugins/console/tsconfig.json b/src/plugins/console/tsconfig.json
index 0dcc23b1c060c..43b94e47eedd4 100644
--- a/src/plugins/console/tsconfig.json
+++ b/src/plugins/console/tsconfig.json
@@ -30,6 +30,7 @@
"@kbn/core-http-router-server-internal",
"@kbn/web-worker-stub",
"@kbn/core-elasticsearch-server",
+ "@kbn/core-http-browser-mocks",
],
"exclude": [
"target/**/*",
diff --git a/test/api_integration/apis/console/autocomplete_entities.ts b/test/api_integration/apis/console/autocomplete_entities.ts
index 6bd899c979a2b..6e13b2fb2856b 100644
--- a/test/api_integration/apis/console/autocomplete_entities.ts
+++ b/test/api_integration/apis/console/autocomplete_entities.ts
@@ -128,7 +128,7 @@ export default ({ getService }: FtrProviderContext) => {
return await supertest.get('/api/console/autocomplete_entities').query(query);
};
- describe('/api/console/autocomplete_entities', () => {
+ describe('/api/console/autocomplete_entities', function () {
const indexName = 'test-index-1';
const aliasName = 'test-alias-1';
const indexTemplateName = 'test-index-template-1';
@@ -238,9 +238,17 @@ export default ({ getService }: FtrProviderContext) => {
expect(body.mappings).to.eql({});
});
- it('should return mappings with fields setting is set to true', async () => {
+ it('should not return mappings with fields setting is set to true without the list of indices is provided', async () => {
const response = await sendRequest({ fields: true });
+ const { body, status } = response;
+ expect(status).to.be(200);
+ expect(Object.keys(body.mappings)).to.not.contain(indexName);
+ });
+
+ it('should return mappings with fields setting is set to true and the list of indices is provided', async () => {
+ const response = await sendRequest({ fields: true, fieldsIndices: indexName });
+
const { body, status } = response;
expect(status).to.be(200);
expect(Object.keys(body.mappings)).to.contain(indexName);
From 9a3e3197481c37db6209f0f0fd00f570d1013cc4 Mon Sep 17 00:00:00 2001
From: Rodney Norris
Date: Mon, 6 Feb 2023 14:31:49 -0600
Subject: [PATCH 013/134] [Enterprise Search][Engines] Clean-up work (#150232)
## Summary
Clean-up work for engines prior to 8.7 feature freeze. Note engines will
be feature flagged in this release. The UI will only be reachable when
the feature flag is turned on by enabling a flag in the enterprise
search yaml config.
- Adds telemetry tracking to all user actions
- Removes the Documents side nav item, this page has been removed from
the scope of MVP
- Added an engines doc link, this is still TBD path may still be updated
to a landing page for 8.7
---
packages/kbn-doc-links/src/get_doc_links.ts | 1 +
packages/kbn-doc-links/src/types.ts | 1 +
.../components/engine/add_indices_flyout.tsx | 13 ++++-
.../components/engine/engine_indices.tsx | 15 +++++-
.../engine/engine_view_header_actions.tsx | 7 +++
.../components/engine/header_docs_action.tsx | 3 +-
.../components/tables/engines_table.tsx | 9 +++-
.../engines/create_engine_flyout.tsx | 4 +-
.../engines/delete_engine_modal.tsx | 6 +++
.../components/engines/engines_list.tsx | 5 +-
.../enterprise_search_content/routes.ts | 1 -
.../shared/doc_links/doc_links.ts | 3 ++
.../applications/shared/layout/nav.test.tsx | 25 ++++-----
.../public/applications/shared/layout/nav.tsx | 52 ++++++++-----------
14 files changed, 88 insertions(+), 57 deletions(-)
diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts
index 0b253cedbc672..6b30af3f39097 100644
--- a/packages/kbn-doc-links/src/get_doc_links.ts
+++ b/packages/kbn-doc-links/src/get_doc_links.ts
@@ -135,6 +135,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
crawlerOverview: `${ENTERPRISE_SEARCH_DOCS}crawler.html`,
deployTrainedModels: `${MACHINE_LEARNING_DOCS}ml-nlp-deploy-models.html`,
documentLevelSecurity: `${ELASTICSEARCH_DOCS}document-level-security.html`,
+ engines: `${ENTERPRISE_SEARCH_DOCS}engines.html`,
ingestPipelines: `${ENTERPRISE_SEARCH_DOCS}ingest-pipelines.html`,
languageAnalyzers: `${ELASTICSEARCH_DOCS}analysis-lang-analyzer.html`,
languageClients: `${ENTERPRISE_SEARCH_DOCS}programming-language-clients.html`,
diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts
index dbfb37905172f..a6284078d3d8d 100644
--- a/packages/kbn-doc-links/src/types.ts
+++ b/packages/kbn-doc-links/src/types.ts
@@ -120,6 +120,7 @@ export interface DocLinks {
readonly crawlerOverview: string;
readonly deployTrainedModels: string;
readonly documentLevelSecurity: string;
+ readonly engines: string;
readonly ingestPipelines: string;
readonly languageAnalyzers: string;
readonly languageClients: string;
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx
index 852e3698b968c..22ab72ba3a405 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx
@@ -99,7 +99,12 @@ export const AddIndicesFlyout: React.FC = ({ onClose }) =
-
+
{i18n.translate(
'xpack.enterpriseSearch.content.engine.indices.addIndicesFlyout.submitButton',
{ defaultMessage: 'Add selected' }
@@ -107,7 +112,11 @@ export const AddIndicesFlyout: React.FC = ({ onClose }) =
-
+
{i18n.translate(
'xpack.enterpriseSearch.content.engine.indices.addIndicesFlyout.cancelButton',
{ defaultMessage: 'Cancel' }
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx
index 0bcdde5e47647..da664b3d97490 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx
@@ -28,6 +28,7 @@ import { indexHealthToHealthColor } from '../../../shared/constants/health_color
import { generateEncodedPath } from '../../../shared/encode_path_params';
import { KibanaLogic } from '../../../shared/kibana';
import { EuiLinkTo } from '../../../shared/react_router_helpers';
+import { TelemetryLogic } from '../../../shared/telemetry/telemetry_logic';
import { SEARCH_INDEX_PATH, EngineViewTabs } from '../../routes';
import { IngestionMethod } from '../../types';
@@ -40,6 +41,7 @@ import { EngineIndicesLogic } from './engine_indices_logic';
import { EngineViewHeaderActions } from './engine_view_header_actions';
export const EngineIndices: React.FC = () => {
+ const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic);
const { engineData, engineName, isLoadingEngine, addIndicesFlyoutOpen } =
useValues(EngineIndicesLogic);
const { removeIndexFromEngine, openAddIndicesFlyout, closeAddIndicesFlyout } =
@@ -157,7 +159,13 @@ export const EngineIndices: React.FC = () => {
},
}
),
- onClick: (index) => setConfirmRemoveIndex(index.name),
+ onClick: (index) => {
+ setConfirmRemoveIndex(index.name);
+ sendEnterpriseSearchTelemetry({
+ action: 'clicked',
+ metric: 'entSearchContent-engines-indices-removeIndex',
+ });
+ },
type: 'icon',
},
],
@@ -180,6 +188,7 @@ export const EngineIndices: React.FC = () => {
{
onConfirm={() => {
removeIndexFromEngine(removeIndexConfirm);
setConfirmRemoveIndex(null);
+ sendEnterpriseSearchTelemetry({
+ action: 'clicked',
+ metric: 'entSearchContent-engines-indices-removeIndexConfirm',
+ });
}}
title={i18n.translate(
'xpack.enterpriseSearch.content.engine.indices.removeIndexConfirm.title',
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx
index ba563a9c23dbc..bd48ee8d6d294 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx
@@ -13,12 +13,15 @@ import { EuiPopover, EuiButtonIcon, EuiText, EuiContextMenu, EuiIcon } from '@el
import { i18n } from '@kbn/i18n';
+import { TelemetryLogic } from '../../../shared/telemetry/telemetry_logic';
+
import { EngineViewLogic } from './engine_view_logic';
export const EngineViewHeaderActions: React.FC = () => {
const { engineData } = useValues(EngineViewLogic);
const { openDeleteEngineModal } = useActions(EngineViewLogic);
+ const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic);
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const toggleActionsPopover = () => setIsActionsPopoverOpen((isPopoverOpen) => !isPopoverOpen);
@@ -66,6 +69,10 @@ export const EngineViewHeaderActions: React.FC = () => {
onClick: () => {
if (engineData) {
openDeleteEngineModal();
+ sendEnterpriseSearchTelemetry({
+ action: 'clicked',
+ metric: 'entSearchContent-engines-engineView-deleteEngine',
+ });
}
},
size: 's',
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx
index 93544cc2e9bbd..e20fbc81689a4 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx
@@ -15,8 +15,9 @@ export const EngineHeaderDocsAction: React.FC = () => (
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx
index b3c5c46b38128..017564343f11e 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx
@@ -7,7 +7,7 @@
import React from 'react';
-import { useValues } from 'kea';
+import { useValues, useActions } from 'kea';
import {
CriteriaWithPagination,
@@ -29,6 +29,7 @@ import { FormattedDateTime } from '../../../../../shared/formatted_date_time';
import { KibanaLogic } from '../../../../../shared/kibana';
import { pageToPagination } from '../../../../../shared/pagination/page_to_pagination';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
+import { TelemetryLogic } from '../../../../../shared/telemetry/telemetry_logic';
import { ENGINE_PATH } from '../../../../routes';
@@ -50,6 +51,7 @@ export const EnginesListTable: React.FC = ({
viewEngineIndices,
}) => {
const { navigateToUrl } = useValues(KibanaLogic);
+ const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic);
const columns: Array> = [
{
field: 'name',
@@ -93,6 +95,7 @@ export const EnginesListTable: React.FC = ({
size="s"
className="engineListTableFlyoutButton"
data-test-subj="engineListTableIndicesFlyoutButton"
+ data-telemetry-id="entSearchContent-engines-table-viewEngineIndices"
onClick={() => viewEngineIndices(engine.name)}
>
= ({
),
onClick: (engine) => {
onDelete(engine);
+ sendEnterpriseSearchTelemetry({
+ action: 'clicked',
+ metric: 'entSearchContent-engines-table-deleteEngine',
+ });
},
},
],
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx
index 06a2509edad53..ae7f7423be7ce 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx
@@ -36,7 +36,7 @@ import { ElasticsearchIndexWithIngestion } from '../../../../../common/types/ind
import { isNotNullish } from '../../../../../common/utils/is_not_nullish';
import { CANCEL_BUTTON_LABEL } from '../../../shared/constants';
-
+import { docLinks } from '../../../shared/doc_links';
import { getErrorsFromHttpResponse } from '../../../shared/flash_messages/handle_api_errors';
import { indexToOption, IndicesSelectComboBox } from './components/indices_select_combobox';
@@ -85,7 +85,7 @@ export const CreateEngineFlyout = ({ onClose }: CreateEngineFlyoutProps) => {
values={{
enginesDocsLink: (
= ({ engineName, onClose }) => {
const { deleteEngine } = useActions(EnginesListLogic);
+ const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic);
const { isDeleteLoading } = useValues(EnginesListLogic);
return (
= ({ engineName
onCancel={onClose}
onConfirm={() => {
deleteEngine({ engineName });
+ sendEnterpriseSearchTelemetry({
+ action: 'clicked',
+ metric: 'entSearchContent-engines-engineView-deleteEngineConfirm',
+ });
}}
cancelButtonText={CANCEL_BUTTON_LABEL}
confirmButtonText={i18n.translate(
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx
index 76e343e234ff3..b927e499ff06c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx
@@ -16,6 +16,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react';
import { INPUT_THROTTLE_DELAY_MS } from '../../../shared/constants/timers';
+import { docLinks } from '../../../shared/doc_links';
import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_template';
@@ -102,12 +103,10 @@ export const EnginesList: React.FC = () => {
documentationUrl: (
- {' '}
- {/* TODO: navigate to documentation url */}{' '}
{i18n.translate('xpack.enterpriseSearch.content.engines.documentation', {
defaultMessage: 'explore our Engines documentation',
})}
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts
index 4185addb31445..57ff05050ebae 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts
@@ -32,7 +32,6 @@ export const ENGINE_TAB_PATH = `${ENGINE_PATH}/:tabId`;
export enum EngineViewTabs {
OVERVIEW = 'overview',
INDICES = 'indices',
- DOCUMENTS = 'documents',
SCHEMA = 'schema',
PREVIEW = 'preview',
API = 'api',
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts
index cc1356fc0c140..073ffe6abca3f 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts
@@ -71,6 +71,7 @@ class DocLinks {
public elasticsearchMapping: string;
public elasticsearchSecureCluster: string;
public enterpriseSearchConfig: string;
+ public enterpriseSearchEngines: string;
public enterpriseSearchMailService: string;
public enterpriseSearchTroubleshootSetup: string;
public enterpriseSearchUsersAccess: string;
@@ -186,6 +187,7 @@ class DocLinks {
this.elasticsearchMapping = '';
this.elasticsearchSecureCluster = '';
this.enterpriseSearchConfig = '';
+ this.enterpriseSearchEngines = '';
this.enterpriseSearchMailService = '';
this.enterpriseSearchTroubleshootSetup = '';
this.enterpriseSearchUsersAccess = '';
@@ -302,6 +304,7 @@ class DocLinks {
this.elasticsearchMapping = docLinks.links.elasticsearch.mapping;
this.elasticsearchSecureCluster = docLinks.links.elasticsearch.secureCluster;
this.enterpriseSearchConfig = docLinks.links.enterpriseSearch.configuration;
+ this.enterpriseSearchEngines = docLinks.links.enterpriseSearch.engines;
this.enterpriseSearchMailService = docLinks.links.enterpriseSearch.mailService;
this.enterpriseSearchTroubleshootSetup = docLinks.links.enterpriseSearch.troubleshootSetup;
this.enterpriseSearchUsersAccess = docLinks.links.enterpriseSearch.usersAccess;
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx
index 17ce1fce0512f..fac9d911be6c8 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx
@@ -468,21 +468,16 @@ describe('useEnterpriseSearchEngineNav', () => {
id: 'enterpriseSearchEngineIndices',
name: 'Indices',
},
- {
- href: `/app/enterprise_search/content/engines/${engineName}/documents`,
- id: 'enterpriseSearchEngineDocuments',
- name: 'Documents',
- },
- {
- href: `/app/enterprise_search/content/engines/${engineName}/schema`,
- id: 'enterpriseSearchEngineSchema',
- name: 'Schema',
- },
- {
- href: `/app/enterprise_search/content/engines/${engineName}/preview`,
- id: 'enterpriseSearchEnginePreview',
- name: 'Preview',
- },
+ // {
+ // href: `/app/enterprise_search/content/engines/${engineName}/schema`,
+ // id: 'enterpriseSearchEngineSchema',
+ // name: 'Schema',
+ // },
+ // {
+ // href: `/app/enterprise_search/content/engines/${engineName}/preview`,
+ // id: 'enterpriseSearchEnginePreview',
+ // name: 'Preview',
+ // },
{
href: `/app/enterprise_search/content/engines/${engineName}/api`,
id: 'enterpriseSearchEngineAPI',
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx
index a6e550e7c58b2..3d81f5d9d7c34 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx
@@ -271,37 +271,27 @@ export const useEnterpriseSearchEngineNav = (engineName?: string, isEmptyState?:
to: `${enginePath}/${EngineViewTabs.INDICES}`,
}),
},
-
- {
- id: 'enterpriseSearchEngineDocuments',
- name: i18n.translate('xpack.enterpriseSearch.nav.engine.documentsTitle', {
- defaultMessage: 'Documents',
- }),
- ...generateNavLink({
- shouldNotCreateHref: true,
- to: `${enginePath}/${EngineViewTabs.DOCUMENTS}`,
- }),
- },
- {
- id: 'enterpriseSearchEngineSchema',
- name: i18n.translate('xpack.enterpriseSearch.nav.engine.schemaTitle', {
- defaultMessage: 'Schema',
- }),
- ...generateNavLink({
- shouldNotCreateHref: true,
- to: `${enginePath}/${EngineViewTabs.SCHEMA}`,
- }),
- },
- {
- id: 'enterpriseSearchEnginePreview',
- name: i18n.translate('xpack.enterpriseSearch.nav.engine.previewTitle', {
- defaultMessage: 'Preview',
- }),
- ...generateNavLink({
- shouldNotCreateHref: true,
- to: `${enginePath}/${EngineViewTabs.PREVIEW}`,
- }),
- },
+ // {
+ // id: 'enterpriseSearchEngineSchema',
+ // name: i18n.translate('xpack.enterpriseSearch.nav.engine.schemaTitle', {
+ // defaultMessage: 'Schema',
+ // }),
+ // ...generateNavLink({
+ // shouldNotCreateHref: true,
+ // to: `${enginePath}/${EngineViewTabs.SCHEMA}`,
+ // }),
+ // },
+ // Hidden until Preview page is available
+ // {
+ // id: 'enterpriseSearchEnginePreview',
+ // name: i18n.translate('xpack.enterpriseSearch.nav.engine.previewTitle', {
+ // defaultMessage: 'Preview',
+ // }),
+ // ...generateNavLink({
+ // shouldNotCreateHref: true,
+ // to: `${enginePath}/${EngineViewTabs.PREVIEW}`,
+ // }),
+ // },
{
id: 'enterpriseSearchEngineAPI',
name: i18n.translate('xpack.enterpriseSearch.nav.engine.apiTitle', {
From c3ea5e5b3a2f0e13e743eea059404c81a07576c1 Mon Sep 17 00:00:00 2001
From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com>
Date: Mon, 6 Feb 2023 15:38:36 -0500
Subject: [PATCH 014/134] [Cases] Improve functional tests (#150117)
This PR tries to improve how often our functional tests succeeds. I also
tried cleaning up a few things that seemed to be slowing the tests down
and also causing errors when the tests were run individually.
Fixes: https://github.com/elastic/kibana/issues/145271
Notable changes:
- I added a value to the `property-actions*` in most places so that the
functional tests can distinguish between a description, comment, or the
case ellipses this seems to work consistently where other methods have
not
Flaky test run:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/1871
---
.../case_action_bar/actions.test.tsx | 39 +++--
.../components/case_action_bar/actions.tsx | 2 +-
.../components/case_action_bar/index.test.tsx | 16 +-
.../components/property_actions/index.tsx | 143 ++++++++++--------
.../user_actions/comment/comment.test.tsx | 24 +--
.../user_actions/description.test.tsx | 16 +-
.../components/user_actions/index.test.tsx | 36 +++--
.../alert_property_actions.test.tsx | 36 ++---
.../description_property_actions.test.tsx | 28 ++--
.../description_property_actions.tsx | 8 +-
.../property_actions.test.tsx | 12 +-
.../property_actions/property_actions.tsx | 12 +-
...ered_attachments_property_actions.test.tsx | 28 ++--
.../user_comment_property_actions.test.tsx | 40 ++---
x-pack/test/functional/services/cases/list.ts | 3 -
.../services/cases/single_case_view.ts | 12 +-
.../apps/cases/deletion.ts | 6 +-
.../apps/cases/view_case.ts | 28 +---
18 files changed, 263 insertions(+), 226 deletions(-)
diff --git a/x-pack/plugins/cases/public/components/case_action_bar/actions.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.test.tsx
index 195d02f7931cf..e04070fecf9ff 100644
--- a/x-pack/plugins/cases/public/components/case_action_bar/actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.test.tsx
@@ -46,8 +46,11 @@ describe('CaseView actions', () => {
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
- wrapper.find('button[data-test-subj="property-actions-ellipses"]').first().simulate('click');
- wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click');
+ wrapper
+ .find('button[data-test-subj="property-actions-case-ellipses"]')
+ .first()
+ .simulate('click');
+ wrapper.find('button[data-test-subj="property-actions-case-trash"]').simulate('click');
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
});
@@ -67,8 +70,11 @@ describe('CaseView actions', () => {
);
- wrapper.find('button[data-test-subj="property-actions-ellipses"]').first().simulate('click');
- wrapper.find('button[data-test-subj="property-actions-copyClipboard"]').simulate('click');
+ wrapper
+ .find('button[data-test-subj="property-actions-case-ellipses"]')
+ .first()
+ .simulate('click');
+ wrapper.find('button[data-test-subj="property-actions-case-copyClipboard"]').simulate('click');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(basicCase.id);
@@ -85,9 +91,14 @@ describe('CaseView actions', () => {
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
- wrapper.find('button[data-test-subj="property-actions-ellipses"]').first().simulate('click');
- expect(wrapper.find('[data-test-subj="property-actions-trash"]').exists()).toBeFalsy();
- expect(wrapper.find('[data-test-subj="property-actions-copyClipboard"]').exists()).toBeTruthy();
+ wrapper
+ .find('button[data-test-subj="property-actions-case-ellipses"]')
+ .first()
+ .simulate('click');
+ expect(wrapper.find('[data-test-subj="property-actions-case-trash"]').exists()).toBeFalsy();
+ expect(
+ wrapper.find('[data-test-subj="property-actions-case-copyClipboard"]').exists()
+ ).toBeTruthy();
});
it('toggle delete modal and confirm', async () => {
@@ -101,8 +112,11 @@ describe('CaseView actions', () => {
);
- wrapper.find('button[data-test-subj="property-actions-ellipses"]').first().simulate('click');
- wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click');
+ wrapper
+ .find('button[data-test-subj="property-actions-case-ellipses"]')
+ .first()
+ .simulate('click');
+ wrapper.find('button[data-test-subj="property-actions-case-trash"]').simulate('click');
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
@@ -126,9 +140,12 @@ describe('CaseView actions', () => {
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
- wrapper.find('button[data-test-subj="property-actions-ellipses"]').first().simulate('click');
+ wrapper
+ .find('button[data-test-subj="property-actions-case-ellipses"]')
+ .first()
+ .simulate('click');
expect(
- wrapper.find('[data-test-subj="property-actions-popout"]').first().prop('aria-label')
+ wrapper.find('[data-test-subj="property-actions-case-popout"]').first().prop('aria-label')
).toEqual(i18n.VIEW_INCIDENT(basicPush.externalTitle));
});
});
diff --git a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx
index d464946b60f82..87cd1fc732a30 100644
--- a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx
+++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx
@@ -84,7 +84,7 @@ const ActionsComponent: React.FC = ({ caseData, currentExternal
return (
-
+
{isModalVisible ? (
{
);
- userEvent.click(screen.getByTestId('property-actions-ellipses'));
+ userEvent.click(screen.getByTestId('property-actions-case-ellipses'));
expect(queryByText('Delete case')).not.toBeInTheDocument();
- expect(queryByTestId('property-actions-trash')).not.toBeInTheDocument();
- expect(queryByTestId('property-actions-copyClipboard')).toBeInTheDocument();
+ expect(queryByTestId('property-actions-case-trash')).not.toBeInTheDocument();
+ expect(queryByTestId('property-actions-case-copyClipboard')).toBeInTheDocument();
});
it('should show the the delete item in the menu when the user does have delete privileges', () => {
@@ -220,7 +220,7 @@ describe('CaseActionBar', () => {
);
- userEvent.click(screen.getByTestId('property-actions-ellipses'));
+ userEvent.click(screen.getByTestId('property-actions-case-ellipses'));
expect(queryByText('Delete case')).toBeInTheDocument();
});
@@ -239,10 +239,10 @@ describe('CaseActionBar', () => {
);
- userEvent.click(screen.getByTestId('property-actions-ellipses'));
+ userEvent.click(screen.getByTestId('property-actions-case-ellipses'));
await waitFor(() => {
- expect(screen.getByTestId('property-actions-popout')).toBeInTheDocument();
+ expect(screen.getByTestId('property-actions-case-popout')).toBeInTheDocument();
});
});
@@ -253,8 +253,8 @@ describe('CaseActionBar', () => {
);
- userEvent.click(screen.getByTestId('property-actions-ellipses'));
+ userEvent.click(screen.getByTestId('property-actions-case-ellipses'));
- expect(screen.queryByTestId('property-actions-popout')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('property-actions-case-popout')).not.toBeInTheDocument();
});
});
diff --git a/x-pack/plugins/cases/public/components/property_actions/index.tsx b/x-pack/plugins/cases/public/components/property_actions/index.tsx
index 61f4c4b6f2cfe..4de52d551bf2f 100644
--- a/x-pack/plugins/cases/public/components/property_actions/index.tsx
+++ b/x-pack/plugins/cases/public/components/property_actions/index.tsx
@@ -17,86 +17,101 @@ export interface PropertyActionButtonProps {
iconType: string;
label: string;
color?: EuiButtonProps['color'];
+ customDataTestSubj?: string;
}
const ComponentId = 'property-actions';
const PropertyActionButton = React.memo(
- ({ disabled = false, onClick, iconType, label, color }) => (
-
- {label}
-
- )
+ ({ disabled = false, onClick, iconType, label, color, customDataTestSubj }) => {
+ const dataTestSubjPrepend = makeDataTestSubjPrepend(customDataTestSubj);
+
+ return (
+
+ {label}
+
+ );
+ }
);
PropertyActionButton.displayName = 'PropertyActionButton';
export interface PropertyActionsProps {
propertyActions: PropertyActionButtonProps[];
+ customDataTestSubj?: string;
}
-export const PropertyActions = React.memo(({ propertyActions }) => {
- const [showActions, setShowActions] = useState(false);
+export const PropertyActions = React.memo(
+ ({ propertyActions, customDataTestSubj }) => {
+ const [showActions, setShowActions] = useState(false);
- const onButtonClick = useCallback(() => {
- setShowActions((prevShowActions) => !prevShowActions);
- }, []);
+ const onButtonClick = useCallback(() => {
+ setShowActions((prevShowActions) => !prevShowActions);
+ }, []);
- const onClosePopover = useCallback((cb?: () => void) => {
- setShowActions(false);
- if (cb != null) {
- cb();
- }
- }, []);
-
- return (
-
+ const onClosePopover = useCallback((cb?: () => void) => {
+ setShowActions(false);
+ if (cb != null) {
+ cb();
}
- id="settingsPopover"
- isOpen={showActions}
- closePopover={onClosePopover}
- repositionOnScroll
- >
-
+ }
+ id="settingsPopover"
+ isOpen={showActions}
+ closePopover={onClosePopover}
+ repositionOnScroll
>
- {propertyActions.map((action, key) => (
-
-
- onClosePopover(action.onClick)}
- />
-
-
- ))}
-
-
- );
-});
+
+ {propertyActions.map((action, key) => (
+
+
+ onClosePopover(action.onClick)}
+ customDataTestSubj={customDataTestSubj}
+ />
+
+
+ ))}
+
+
+ );
+ }
+);
PropertyActions.displayName = 'PropertyActions';
+
+const makeDataTestSubjPrepend = (customDataTestSubj?: string) => {
+ return customDataTestSubj == null ? ComponentId : `${ComponentId}-${customDataTestSubj}`;
+};
diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx
index 77f1af32529e4..2ecc4b32d0837 100644
--- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx
@@ -223,13 +223,13 @@ describe('createCommentUserActionBuilder', () => {
);
expect(result.getByText('Solve this fast!')).toBeInTheDocument();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-pencil')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-pencil'));
+ expect(result.queryByTestId('property-actions-user-action-pencil')).toBeInTheDocument();
+ userEvent.click(result.getByTestId('property-actions-user-action-pencil'));
await waitFor(() => {
expect(builderArgs.handleManageMarkdownEditId).toHaveBeenCalledWith('basic-comment-id');
@@ -254,13 +254,13 @@ describe('createCommentUserActionBuilder', () => {
);
expect(result.getByText('Solve this fast!')).toBeInTheDocument();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-quote')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-quote'));
+ expect(result.queryByTestId('property-actions-user-action-quote')).toBeInTheDocument();
+ userEvent.click(result.getByTestId('property-actions-user-action-quote'));
await waitFor(() => {
expect(builderArgs.handleManageQuote).toHaveBeenCalledWith('Solve this fast!');
@@ -769,14 +769,14 @@ describe('createCommentUserActionBuilder', () => {
});
const deleteAttachment = async (result: RenderResult, deleteIcon: string, buttonLabel: string) => {
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId(`property-actions-${deleteIcon}`)).toBeInTheDocument();
+ expect(result.queryByTestId(`property-actions-user-action-${deleteIcon}`)).toBeInTheDocument();
- userEvent.click(result.getByTestId(`property-actions-${deleteIcon}`));
+ userEvent.click(result.getByTestId(`property-actions-user-action-${deleteIcon}`));
await waitFor(() => {
expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument();
diff --git a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx
index 9fddcbb3cce6a..5b519b6739bdd 100644
--- a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx
@@ -58,13 +58,13 @@ describe('createDescriptionUserActionBuilder ', () => {
);
- expect(res.getByTestId('property-actions')).toBeInTheDocument();
+ expect(res.getByTestId('property-actions-description')).toBeInTheDocument();
- userEvent.click(res.getByTestId('property-actions-ellipses'));
+ userEvent.click(res.getByTestId('property-actions-description-ellipses'));
await waitForEuiPopoverOpen();
- expect(res.queryByTestId('property-actions-pencil')).toBeInTheDocument();
- userEvent.click(res.getByTestId('property-actions-pencil'));
+ expect(res.queryByTestId('property-actions-description-pencil')).toBeInTheDocument();
+ userEvent.click(res.getByTestId('property-actions-description-pencil'));
await waitFor(() => {
expect(builderArgs.handleManageMarkdownEditId).toHaveBeenCalledWith('description');
@@ -84,13 +84,13 @@ describe('createDescriptionUserActionBuilder ', () => {
);
- expect(res.getByTestId('property-actions')).toBeInTheDocument();
+ expect(res.getByTestId('property-actions-description')).toBeInTheDocument();
- userEvent.click(res.getByTestId('property-actions-ellipses'));
+ userEvent.click(res.getByTestId('property-actions-description-ellipses'));
await waitForEuiPopoverOpen();
- expect(res.queryByTestId('property-actions-quote')).toBeInTheDocument();
- userEvent.click(res.getByTestId('property-actions-quote'));
+ expect(res.queryByTestId('property-actions-description-quote')).toBeInTheDocument();
+ userEvent.click(res.getByTestId('property-actions-description-quote'));
await waitFor(() => {
expect(builderArgs.handleManageQuote).toHaveBeenCalledWith('Security banana Issue');
diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx
index 2cfc535940f45..8923a45c48c2a 100644
--- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx
@@ -197,13 +197,13 @@ describe(`UserActions`, () => {
wrapper
.find(
- `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]`
)
.first()
.simulate('click');
wrapper
.find(
- `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]`
)
.first()
.simulate('click');
@@ -241,14 +241,14 @@ describe(`UserActions`, () => {
wrapper
.find(
- `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]`
)
.first()
.simulate('click');
wrapper
.find(
- `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]`
)
.first()
.simulate('click');
@@ -293,12 +293,16 @@ describe(`UserActions`, () => {
);
wrapper
- .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`)
+ .find(
+ `[data-test-subj="description-action"] [data-test-subj="property-actions-description-ellipses"]`
+ )
.first()
.simulate('click');
wrapper
- .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`)
+ .find(
+ `[data-test-subj="description-action"] [data-test-subj="property-actions-description-pencil"]`
+ )
.first()
.simulate('click');
@@ -341,12 +345,16 @@ describe(`UserActions`, () => {
expect(wrapper.find(`.euiMarkdownEditorTextArea`).text()).not.toContain(quoteableText);
wrapper
- .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`)
+ .find(
+ `[data-test-subj="description-action"] [data-test-subj="property-actions-description-ellipses"]`
+ )
.first()
.simulate('click');
wrapper
- .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`)
+ .find(
+ `[data-test-subj="description-action"] [data-test-subj="property-actions-description-quote"]`
+ )
.first()
.simulate('click');
@@ -402,14 +410,14 @@ describe(`UserActions`, () => {
wrapper
.find(
- `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]`
)
.first()
.simulate('click');
wrapper
.find(
- `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]`
+ `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]`
)
.first()
.simulate('click');
@@ -463,12 +471,16 @@ describe(`UserActions`, () => {
.simulate('change', { target: { value: newComment } });
wrapper
- .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`)
+ .find(
+ `[data-test-subj="description-action"] [data-test-subj="property-actions-description-ellipses"]`
+ )
.first()
.simulate('click');
wrapper
- .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`)
+ .find(
+ `[data-test-subj="description-action"] [data-test-subj="property-actions-description-pencil"]`
+ )
.first()
.simulate('click');
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx
index dc8a57b8477f6..79636d52572ba 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx
@@ -34,26 +34,26 @@ describe('AlertPropertyActions', () => {
it('renders the correct number of actions', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.getByTestId('property-actions-group').children.length).toBe(1);
- expect(result.queryByTestId('property-actions-minusInCircle')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action-group').children.length).toBe(1);
+ expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument();
});
it('renders the modal info correctly for one alert', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-minusInCircle')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-minusInCircle'));
+ userEvent.click(result.getByTestId('property-actions-user-action-minusInCircle'));
await waitFor(() => {
expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument();
@@ -66,14 +66,14 @@ describe('AlertPropertyActions', () => {
it('renders the modal info correctly for multiple alert', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-minusInCircle')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-minusInCircle'));
+ userEvent.click(result.getByTestId('property-actions-user-action-minusInCircle'));
await waitFor(() => {
expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument();
@@ -86,14 +86,14 @@ describe('AlertPropertyActions', () => {
it('remove alerts correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-minusInCircle')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-minusInCircle'));
+ userEvent.click(result.getByTestId('property-actions-user-action-minusInCircle'));
await waitFor(() => {
expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument();
@@ -107,13 +107,13 @@ describe('AlertPropertyActions', () => {
appMock = createAppMockRenderer({ permissions: noCasesPermissions() });
const result = appMock.render();
- expect(result.queryByTestId('property-actions')).not.toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
});
it('does show the property actions with only delete permissions', async () => {
appMock = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() });
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
});
});
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.test.tsx
index bfaa349caf46f..4a164c5e1fbda 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.test.tsx
@@ -29,27 +29,27 @@ describe('DescriptionPropertyActions', () => {
it('renders the correct number of actions', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-description')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-description-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.getByTestId('property-actions-group').children.length).toBe(2);
- expect(result.queryByTestId('property-actions-pencil')).toBeInTheDocument();
- expect(result.queryByTestId('property-actions-quote')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-description-group').children.length).toBe(2);
+ expect(result.queryByTestId('property-actions-description-pencil')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-description-quote')).toBeInTheDocument();
});
it('edits the description correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-description')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-description-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-pencil')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-description-pencil')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-pencil'));
+ userEvent.click(result.getByTestId('property-actions-description-pencil'));
expect(props.onEdit).toHaveBeenCalled();
});
@@ -57,14 +57,14 @@ describe('DescriptionPropertyActions', () => {
it('quotes the description correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-description')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-description-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-quote')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-description-quote')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-quote'));
+ userEvent.click(result.getByTestId('property-actions-description-quote'));
expect(props.onQuote).toHaveBeenCalled();
});
@@ -73,6 +73,6 @@ describe('DescriptionPropertyActions', () => {
appMock = createAppMockRenderer({ permissions: noCasesPermissions() });
const result = appMock.render();
- expect(result.queryByTestId('property-actions')).not.toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-description')).not.toBeInTheDocument();
});
});
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.tsx
index 5ef72a5590140..1b948f57e6448 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/description_property_actions.tsx
@@ -45,7 +45,13 @@ const DescriptionPropertyActionsComponent: React.FC = ({ isLoading, onEdi
];
}, [permissions.update, permissions.create, onEdit, onQuote]);
- return ;
+ return (
+
+ );
};
DescriptionPropertyActionsComponent.displayName = 'DescriptionPropertyActions';
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx
index c7cfdb25bb359..ca310ab5121aa 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.test.tsx
@@ -36,19 +36,19 @@ describe('UserActionPropertyActions', () => {
const result = appMock.render();
expect(result.getByTestId('user-action-title-loading')).toBeInTheDocument();
- expect(result.queryByTestId('property-actions')).not.toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
});
it('renders the property actions', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.getByTestId('property-actions-group').children.length).toBe(1);
- expect(result.queryByTestId('property-actions-pencil')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action-group').children.length).toBe(1);
+ expect(result.queryByTestId('property-actions-user-action-pencil')).toBeInTheDocument();
});
it('does not render if properties are empty', async () => {
@@ -56,7 +56,7 @@ describe('UserActionPropertyActions', () => {
);
- expect(result.queryByTestId('property-actions')).not.toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
expect(result.queryByTestId('user-action-title-loading')).not.toBeInTheDocument();
});
});
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx
index abf897404711a..975a8670ab096 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/property_actions.tsx
@@ -13,9 +13,14 @@ import { PropertyActions } from '../../property_actions';
interface Props {
isLoading: boolean;
propertyActions: PropertyActionButtonProps[];
+ customDataTestSubj?: string;
}
-const UserActionPropertyActionsComponent: React.FC = ({ isLoading, propertyActions }) => {
+const UserActionPropertyActionsComponent: React.FC = ({
+ isLoading,
+ propertyActions,
+ customDataTestSubj = 'user-action',
+}) => {
if (propertyActions.length === 0) {
return null;
}
@@ -25,7 +30,10 @@ const UserActionPropertyActionsComponent: React.FC = ({ isLoading, proper
{isLoading ? (
) : (
-
+
)}
);
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx
index a756f43893e03..9e391fa6e7042 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx
@@ -33,26 +33,26 @@ describe('RegisteredAttachmentsPropertyActions', () => {
it('renders the correct number of actions', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.getByTestId('property-actions-group').children.length).toBe(1);
- expect(result.queryByTestId('property-actions-trash')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action-group').children.length).toBe(1);
+ expect(result.queryByTestId('property-actions-user-action-trash')).toBeInTheDocument();
});
it('renders the modal info correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-trash')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-trash')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-trash'));
+ userEvent.click(result.getByTestId('property-actions-user-action-trash'));
await waitFor(() => {
expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument();
@@ -65,14 +65,14 @@ describe('RegisteredAttachmentsPropertyActions', () => {
it('remove attachments correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-trash')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-trash')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-trash'));
+ userEvent.click(result.getByTestId('property-actions-user-action-trash'));
await waitFor(() => {
expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument();
@@ -86,13 +86,13 @@ describe('RegisteredAttachmentsPropertyActions', () => {
appMock = createAppMockRenderer({ permissions: noCasesPermissions() });
const result = appMock.render();
- expect(result.queryByTestId('property-actions')).not.toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
});
it('does show the property actions with only delete permissions', async () => {
appMock = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() });
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
});
});
diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.test.tsx
index 557dae707c20f..643662dfbc3f7 100644
--- a/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/user_comment_property_actions.test.tsx
@@ -35,28 +35,28 @@ describe('UserCommentPropertyActions', () => {
it('renders the correct number of actions', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.getByTestId('property-actions-group').children.length).toBe(3);
- expect(result.queryByTestId('property-actions-pencil')).toBeInTheDocument();
- expect(result.queryByTestId('property-actions-trash')).toBeInTheDocument();
- expect(result.queryByTestId('property-actions-quote')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action-group').children.length).toBe(3);
+ expect(result.queryByTestId('property-actions-user-action-pencil')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-trash')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-quote')).toBeInTheDocument();
});
it('edits the comment correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-pencil')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-pencil')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-pencil'));
+ userEvent.click(result.getByTestId('property-actions-user-action-pencil'));
expect(props.onEdit).toHaveBeenCalled();
});
@@ -64,14 +64,14 @@ describe('UserCommentPropertyActions', () => {
it('quotes the comment correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-quote')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-quote')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-quote'));
+ userEvent.click(result.getByTestId('property-actions-user-action-quote'));
expect(props.onQuote).toHaveBeenCalled();
});
@@ -79,14 +79,14 @@ describe('UserCommentPropertyActions', () => {
it('deletes the comment correctly', async () => {
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-ellipses'));
+ userEvent.click(result.getByTestId('property-actions-user-action-ellipses'));
await waitForEuiPopoverOpen();
- expect(result.queryByTestId('property-actions-trash')).toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action-trash')).toBeInTheDocument();
- userEvent.click(result.getByTestId('property-actions-trash'));
+ userEvent.click(result.getByTestId('property-actions-user-action-trash'));
await waitFor(() => {
expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument();
@@ -100,13 +100,13 @@ describe('UserCommentPropertyActions', () => {
appMock = createAppMockRenderer({ permissions: noCasesPermissions() });
const result = appMock.render();
- expect(result.queryByTestId('property-actions')).not.toBeInTheDocument();
+ expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument();
});
it('does show the property actions with only delete permissions', async () => {
appMock = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() });
const result = appMock.render();
- expect(result.getByTestId('property-actions')).toBeInTheDocument();
+ expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument();
});
});
diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts
index 1e420cd37368c..ced5c5acedca2 100644
--- a/x-pack/test/functional/services/cases/list.ts
+++ b/x-pack/test/functional/services/cases/list.ts
@@ -81,9 +81,6 @@ export function CasesTableServiceProvider(
rows = await find.allByCssSelector('[data-test-subj*="cases-table-row-"', 100);
if (rows.length > 0) {
await this.bulkDeleteAllCases();
- // wait for a second
- await new Promise((r) => setTimeout(r, 1000));
- await header.waitUntilLoadingHasFinished();
}
} while (rows.length > 0);
},
diff --git a/x-pack/test/functional/services/cases/single_case_view.ts b/x-pack/test/functional/services/cases/single_case_view.ts
index bd9377ebd5abd..b8bea0841a605 100644
--- a/x-pack/test/functional/services/cases/single_case_view.ts
+++ b/x-pack/test/functional/services/cases/single_case_view.ts
@@ -20,14 +20,12 @@ export function CasesSingleViewServiceProvider({ getService, getPageObject }: Ft
return {
async deleteCase() {
- const caseActions = await testSubjects.findDescendant(
- 'property-actions-ellipses',
- await testSubjects.find('case-view-actions')
- );
+ await retry.try(async () => {
+ await testSubjects.click('property-actions-case-ellipses');
+ await testSubjects.existOrFail('property-actions-case-trash', { timeout: 100 });
+ });
- await caseActions.click();
- await testSubjects.existOrFail('property-actions-trash');
- await common.clickAndValidate('property-actions-trash', 'confirmModalConfirmButton');
+ await common.clickAndValidate('property-actions-case-trash', 'confirmModalConfirmButton');
await testSubjects.click('confirmModalConfirmButton');
await header.waitUntilLoadingHasFinished();
},
diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts b/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts
index 0f8d551ce86aa..4179c549484fb 100644
--- a/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts
+++ b/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts
@@ -17,8 +17,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const cases = getService('cases');
- // Failing: See https://github.com/elastic/kibana/issues/145271
- describe.skip('cases deletion sub privilege', () => {
+ describe('cases deletion sub privilege', () => {
before(async () => {
await createUsersAndRoles(getService, users, roles);
await PageObjects.security.forceLogout();
@@ -101,7 +100,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
it(`User ${user.username} cannot delete a case while on a specific case page`, async () => {
- await testSubjects.missingOrFail('case-view-actions');
+ await testSubjects.click('property-actions-case-ellipses');
+ await testSubjects.missingOrFail('property-actions-case-trash');
});
});
diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts
index 876857721966f..e808aaa8a4463 100644
--- a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts
+++ b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts
@@ -14,7 +14,7 @@ import {
createUsersAndRoles,
deleteUsersAndRoles,
} from '../../../cases_api_integration/common/lib/authentication';
-import { users, roles, casesAllUser } from './common';
+import { users, roles, casesAllUser, casesAllUser2 } from './common';
export default ({ getPageObject, getService }: FtrProviderContext) => {
const header = getPageObject('header');
@@ -282,22 +282,14 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await header.waitUntilLoadingHasFinished();
- const propertyActions = await find.allByCssSelector(
- '[data-test-subj*="property-actions-ellipses"]'
- );
-
- propertyActions[propertyActions.length - 1].click();
+ await testSubjects.click('property-actions-user-action-ellipses');
await header.waitUntilLoadingHasFinished();
- const editAction = await find.byCssSelector(
- '[data-test-subj*="property-actions-pencil"]'
- );
+ await testSubjects.click('property-actions-user-action-pencil');
await header.waitUntilLoadingHasFinished();
- await editAction.click();
-
const editCommentTextArea = await find.byCssSelector(
'[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea'
);
@@ -315,22 +307,14 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
});
it('shows unsaved description message when page is refreshed', async () => {
- const propertyActions = await find.allByCssSelector(
- '[data-test-subj*="property-actions-ellipses"]'
- );
-
- propertyActions[1].click();
+ await testSubjects.click('property-actions-description-ellipses');
await header.waitUntilLoadingHasFinished();
- const editAction = await find.byCssSelector(
- '[data-test-subj*="property-actions-pencil"]'
- );
+ await testSubjects.click('property-actions-description-pencil');
await header.waitUntilLoadingHasFinished();
- await editAction.click();
-
const editCommentTextArea = await find.byCssSelector(
'[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea'
);
@@ -379,7 +363,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
describe('Assignees field', () => {
before(async () => {
await createUsersAndRoles(getService, users, roles);
- await cases.api.activateUserProfiles([casesAllUser]);
+ await cases.api.activateUserProfiles([casesAllUser, casesAllUser2]);
});
after(async () => {
From 6de805636feef96fad6686ba37d58c9a3dd319e2 Mon Sep 17 00:00:00 2001
From: Sloane Perrault
Date: Mon, 6 Feb 2023 15:44:41 -0500
Subject: [PATCH 015/134] [Enterprise Search] Engines Overview with indices and
documents count stats (#149980)
## Summary
Adds Engine overview page. Includes panel with document and index counts
in stats components
### Checklist
Delete any items that are not applicable to this PR.
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
### For maintainers
- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---
.../enterprise_search/common/types/engines.ts | 9 +-
...ngine_field_capabilities_api_logic.test.ts | 31 +++++
...tch_engine_field_capabilities_api_logic.ts | 34 ++++++
.../components/engine/engine_overview.tsx | 109 ++++++++++++++++++
.../engine/engine_overview_logic.test.ts | 39 +++++++
.../engine/engine_overview_logic.ts | 89 ++++++++++++++
.../components/engine/engine_view.tsx | 6 +
.../components/engine/engine_view_logic.ts | 8 +-
.../routes/enterprise_search/engines.ts | 10 ++
9 files changed, 330 insertions(+), 5 deletions(-)
create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.test.ts
create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.ts
create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.test.ts
create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.ts
diff --git a/x-pack/plugins/enterprise_search/common/types/engines.ts b/x-pack/plugins/enterprise_search/common/types/engines.ts
index 3926be635c5bd..8a0ef6d216a20 100644
--- a/x-pack/plugins/enterprise_search/common/types/engines.ts
+++ b/x-pack/plugins/enterprise_search/common/types/engines.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { HealthStatus } from '@elastic/elasticsearch/lib/api/types';
+import { HealthStatus, FieldCapsResponse } from '@elastic/elasticsearch/lib/api/types';
export interface EnterpriseSearchEnginesResponse {
meta: {
@@ -37,3 +37,10 @@ export interface EnterpriseSearchEngineIndex {
name: string;
source: 'api' | 'connector' | 'crawler';
}
+
+export interface EnterpriseSearchEngineFieldCapabilities {
+ created: string;
+ field_capabilities: FieldCapsResponse;
+ name: string;
+ updated: string;
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.test.ts
new file mode 100644
index 0000000000000..5eb05fbed4c8f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.test.ts
@@ -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 { mockHttpValues } from '../../../__mocks__/kea_logic';
+
+import { nextTick } from '@kbn/test-jest-helpers';
+
+import { fetchEngineFieldCapabilities } from './fetch_engine_field_capabilities_api_logic';
+
+describe('FetchEngineFieldCapabilitiesApiLogic', () => {
+ const { http } = mockHttpValues;
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ describe('fetchEngineFieldCapabilities', () => {
+ it('requests the field_capabilities api', async () => {
+ const promise = Promise.resolve({ result: 'result' });
+ http.get.mockReturnValue(promise);
+ const result = fetchEngineFieldCapabilities({ engineName: 'foobar' });
+ await nextTick();
+ expect(http.get).toHaveBeenCalledWith(
+ '/internal/enterprise_search/engines/foobar/field_capabilities'
+ );
+ await expect(result).resolves.toEqual({ result: 'result' });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.ts
new file mode 100644
index 0000000000000..7f5d196a5a02d
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/engines/fetch_engine_field_capabilities_api_logic.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 { EnterpriseSearchEngineFieldCapabilities } from '../../../../../common/types/engines';
+import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic';
+import { HttpLogic } from '../../../shared/http';
+
+export interface FetchEngineFieldCapabilitiesApiParams {
+ engineName: string;
+}
+
+export type FetchEngineFieldCapabilitiesApiResponse = EnterpriseSearchEngineFieldCapabilities;
+
+export const fetchEngineFieldCapabilities = async ({
+ engineName,
+}: FetchEngineFieldCapabilitiesApiParams): Promise => {
+ const route = `/internal/enterprise_search/engines/${engineName}/field_capabilities`;
+
+ return await HttpLogic.values.http.get(route);
+};
+
+export const FetchEngineFieldCapabilitiesApiLogic = createApiLogic(
+ ['fetch_engine_field_capabilities_api_logic'],
+ fetchEngineFieldCapabilities
+);
+
+export type FetchEngineFieldCapabilitiesApiLogicActions = Actions<
+ FetchEngineFieldCapabilitiesApiParams,
+ FetchEngineFieldCapabilitiesApiResponse
+>;
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview.tsx
new file mode 100644
index 0000000000000..83312c8290e21
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview.tsx
@@ -0,0 +1,109 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+
+import { useValues } from 'kea';
+
+import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiStat } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { generateEncodedPath } from '../../../shared/encode_path_params';
+import { EuiLinkTo } from '../../../shared/react_router_helpers';
+import { EngineViewTabs, ENGINE_TAB_PATH } from '../../routes';
+import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_template';
+
+import { EngineOverviewLogic } from './engine_overview_logic';
+import { EngineViewHeaderActions } from './engine_view_header_actions';
+
+export const EngineOverview: React.FC = () => {
+ const { engineName, indicesCount, documentsCount, fieldsCount, isLoadingEngine } =
+ useValues(EngineOverviewLogic);
+
+ return (
+ ],
+ }}
+ engineName={engineName}
+ >
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.test.ts
new file mode 100644
index 0000000000000..0a012a07fd9da
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.test.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { LogicMounter } from '../../../__mocks__/kea_logic';
+
+import { Status } from '../../../../../common/types/api';
+
+import { EngineOverviewLogic, EngineOverviewValues } from './engine_overview_logic';
+
+const DEFAULT_VALUES: EngineOverviewValues = {
+ documentsCount: 0,
+ engineData: undefined,
+ engineFieldCapabilitiesApiStatus: Status.IDLE,
+ engineFieldCapabilitiesData: undefined,
+ engineName: '',
+ fieldsCount: 0,
+ indices: [],
+ indicesCount: 0,
+ isLoadingEngine: true,
+};
+
+describe('EngineOverviewLogic', () => {
+ const { mount } = new LogicMounter(EngineOverviewLogic);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useRealTimers();
+
+ mount();
+ });
+
+ it('has expected default values', () => {
+ expect(EngineOverviewLogic.values).toEqual(DEFAULT_VALUES);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.ts
new file mode 100644
index 0000000000000..f6a8508d131b1
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_overview_logic.ts
@@ -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 { kea, MakeLogicType } from 'kea';
+
+import { Status } from '../../../../../common/types/api';
+import { EnterpriseSearchEngineIndex } from '../../../../../common/types/engines';
+
+import { FetchEngineFieldCapabilitiesApiLogic } from '../../api/engines/fetch_engine_field_capabilities_api_logic';
+
+import { EngineNameLogic } from './engine_name_logic';
+import { EngineViewLogic } from './engine_view_logic';
+
+export interface EngineOverviewActions {
+ fetchEngineFieldCapabilities: typeof FetchEngineFieldCapabilitiesApiLogic.actions.makeRequest;
+}
+export interface EngineOverviewValues {
+ documentsCount: number;
+ engineData: typeof EngineViewLogic.values.engineData;
+ engineFieldCapabilitiesApiStatus: typeof FetchEngineFieldCapabilitiesApiLogic.values.status;
+ engineFieldCapabilitiesData: typeof FetchEngineFieldCapabilitiesApiLogic.values.data;
+ engineName: typeof EngineNameLogic.values.engineName;
+ fieldsCount: number;
+ indices: EnterpriseSearchEngineIndex[];
+ indicesCount: number;
+ isLoadingEngine: typeof EngineViewLogic.values.isLoadingEngine;
+}
+
+export const EngineOverviewLogic = kea>({
+ actions: {},
+ connect: {
+ actions: [
+ EngineNameLogic,
+ ['setEngineName'],
+ FetchEngineFieldCapabilitiesApiLogic,
+ ['makeRequest as fetchEngineFieldCapabilities'],
+ ],
+ values: [
+ EngineNameLogic,
+ ['engineName'],
+ EngineViewLogic,
+ ['engineData', 'isLoadingEngine'],
+ FetchEngineFieldCapabilitiesApiLogic,
+ ['data as engineFieldCapabilitiesData', 'status as engineFieldCapabilitiesApiStatus'],
+ ],
+ },
+ events: ({ actions, values }) => ({
+ afterMount: () => {
+ if (values.engineFieldCapabilitiesApiStatus !== Status.SUCCESS && !!values.engineName) {
+ actions.fetchEngineFieldCapabilities({
+ engineName: values.engineName,
+ });
+ }
+ },
+ }),
+ listeners: ({ actions }) => ({
+ setEngineName: ({ engineName }) => {
+ actions.fetchEngineFieldCapabilities({ engineName });
+ },
+ }),
+ path: ['enterprise_search', 'content', 'engine_overview_logic'],
+ reducers: {},
+ selectors: ({ selectors }) => ({
+ documentsCount: [
+ () => [selectors.indices],
+ (indices: EngineOverviewValues['indices']) =>
+ indices.reduce((sum, { count }) => sum + count, 0),
+ ],
+ fieldsCount: [
+ () => [selectors.engineFieldCapabilitiesData],
+ (engineFieldCapabilitiesData: EngineOverviewValues['engineFieldCapabilitiesData']) =>
+ Object.values(engineFieldCapabilitiesData?.field_capabilities?.fields ?? {}).filter(
+ (value) => !Object.values(value).some((field) => !!field.metadata_field)
+ ).length,
+ ],
+ indices: [
+ () => [selectors.engineData],
+ (engineData: EngineOverviewValues['engineData']) => engineData?.indices ?? [],
+ ],
+ indicesCount: [
+ () => [selectors.indices],
+ (indices: EngineOverviewValues['indices']) => indices.length,
+ ],
+ }),
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx
index e6057a2d1b2bd..0c65e8008f74e 100644
--- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view.tsx
@@ -21,6 +21,7 @@ import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_temp
import { EngineAPI } from './engine_api/engine_api';
import { EngineError } from './engine_error';
import { EngineIndices } from './engine_indices';
+import { EngineOverview } from './engine_overview';
import { EngineViewHeaderActions } from './engine_view_header_actions';
import { EngineViewLogic } from './engine_view_logic';
import { EngineHeaderDocsAction } from './header_docs_action';
@@ -66,6 +67,11 @@ export const EngineView: React.FC = () => {
) : null}
+ >({
+ actions: {
+ closeDeleteEngineModal: true,
+ openDeleteEngineModal: true,
+ },
connect: {
actions: [
FetchEngineApiLogic,
@@ -53,10 +57,6 @@ export const EngineViewLogic = kea ({
deleteSuccess: () => {
actions.closeDeleteEngineModal();
diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/engines.ts
index c64c86b35fadb..2a0469d77cd48 100644
--- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/engines.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/engines.ts
@@ -128,4 +128,14 @@ export function registerEnginesRoutes({
path: '/api/engines/:engine_name/_search',
})
);
+
+ router.get(
+ {
+ path: '/internal/enterprise_search/engines/{engine_name}/field_capabilities',
+ validate: { params: schema.object({ engine_name: schema.string() }) },
+ },
+ enterpriseSearchRequestHandler.createRequest({
+ path: '/api/engines/:engine_name/field_capabilities',
+ })
+ );
}
From 6e5fe42aaa607e71a3c15824cdb8306e1255f955 Mon Sep 17 00:00:00 2001
From: Steph Milovic
Date: Mon, 6 Feb 2023 13:59:20 -0700
Subject: [PATCH 016/134] [Security solution] Network page maps, fix bad side
effects (#149368)
---
.../explore/containers/fields/index.test.ts | 51 +++++++++----------
.../public/explore/containers/fields/index.ts | 30 +++++++----
.../embeddables/embedded_map.test.tsx | 19 ++++---
.../components/embeddables/embedded_map.tsx | 31 +++++------
4 files changed, 73 insertions(+), 58 deletions(-)
diff --git a/x-pack/plugins/security_solution/public/explore/containers/fields/index.test.ts b/x-pack/plugins/security_solution/public/explore/containers/fields/index.test.ts
index b10801ed41364..03084ef42dd8d 100644
--- a/x-pack/plugins/security_solution/public/explore/containers/fields/index.test.ts
+++ b/x-pack/plugins/security_solution/public/explore/containers/fields/index.test.ts
@@ -6,30 +6,15 @@
*/
import { useKibana } from '../../../common/lib/kibana';
import { useIsFieldInIndexPattern } from '.';
+import { renderHook } from '@testing-library/react-hooks';
+import { getRequiredMapsFields } from '../../network/components/embeddables/map_config';
jest.mock('../../../common/lib/kibana');
+jest.mock('../../network/components/embeddables/map_config');
const mockUseKibana = useKibana as jest.Mock;
describe('useIsFieldInIndexPattern', () => {
- beforeAll(() => {
- mockUseKibana.mockReturnValue({
- services: {
- data: {
- dataViews: {
- getFieldsForWildcard: () => [],
- },
- },
- },
- });
- });
beforeEach(() => {
jest.clearAllMocks();
- });
- it('returns false when no fields in field list exist in the index pattern', async () => {
- const isFieldInIndexPattern = useIsFieldInIndexPattern();
- const res = await isFieldInIndexPattern('index-pattern-*', ['fields.list']);
- expect(res).toEqual(false);
- });
- it('returns false when some but not all fields in field list exist in the index pattern', async () => {
mockUseKibana.mockReturnValue({
services: {
http: {},
@@ -40,23 +25,37 @@ describe('useIsFieldInIndexPattern', () => {
},
},
});
- const isFieldInIndexPattern = useIsFieldInIndexPattern();
- const res = await isFieldInIndexPattern('index-pattern-*', ['fields.list', 'another']);
- expect(res).toEqual(false);
+ (getRequiredMapsFields as jest.Mock).mockReturnValue(['fields.list']);
});
- it('returns true when all fields in field list exist in the index pattern', async () => {
+ it('returns false when no fields in field list exist in the index pattern', async () => {
mockUseKibana.mockReturnValue({
services: {
- http: {},
data: {
dataViews: {
- getFieldsForWildcard: () => [{ name: 'fields.list' }],
+ getFieldsForWildcard: () => [],
},
},
},
});
- const isFieldInIndexPattern = useIsFieldInIndexPattern();
- const res = await isFieldInIndexPattern('index-pattern-*', ['fields.list']);
+ const {
+ result: { current: isFieldInIndexPattern },
+ } = renderHook(useIsFieldInIndexPattern);
+ const res = await isFieldInIndexPattern('index-pattern-*');
+ expect(res).toEqual(false);
+ });
+ it('returns false when some but not all fields in field list exist in the index pattern', async () => {
+ (getRequiredMapsFields as jest.Mock).mockReturnValue(['fields.list', 'another']);
+ const {
+ result: { current: isFieldInIndexPattern },
+ } = renderHook(useIsFieldInIndexPattern);
+ const res = await isFieldInIndexPattern('index-pattern-*');
+ expect(res).toEqual(false);
+ });
+ it('returns true when all fields in field list exist in the index pattern', async () => {
+ const {
+ result: { current: isFieldInIndexPattern },
+ } = renderHook(useIsFieldInIndexPattern);
+ const res = await isFieldInIndexPattern('index-pattern-*');
expect(res).toEqual(true);
});
});
diff --git a/x-pack/plugins/security_solution/public/explore/containers/fields/index.ts b/x-pack/plugins/security_solution/public/explore/containers/fields/index.ts
index 81c6765e409c6..b120956ab24c4 100644
--- a/x-pack/plugins/security_solution/public/explore/containers/fields/index.ts
+++ b/x-pack/plugins/security_solution/public/explore/containers/fields/index.ts
@@ -5,18 +5,30 @@
* 2.0.
*/
+import { useMemo } from 'react';
+import memoizeOne from 'memoize-one';
+import { getRequiredMapsFields } from '../../network/components/embeddables/map_config';
import { useKibana } from '../../../common/lib/kibana';
-type FieldValidationCheck = (pattern: string, fieldsList: string[]) => Promise;
+type FieldValidationCheck = (pattern: string) => Promise;
export const useIsFieldInIndexPattern = (): FieldValidationCheck => {
const { dataViews } = useKibana().services.data;
- return async (pattern: string, fieldsList: string[]) => {
- const fields = await dataViews.getFieldsForWildcard({
- pattern,
- fields: fieldsList,
- });
- const fieldNames = fields.map((f) => f.name);
- return fieldsList.every((field) => fieldNames.includes(field));
- };
+
+ return useMemo(
+ () =>
+ memoizeOne(
+ async (pattern: string) => {
+ const fieldsList = getRequiredMapsFields(pattern);
+ const fields = await dataViews.getFieldsForWildcard({
+ pattern,
+ fields: fieldsList,
+ });
+ const fieldNames = fields.map((f) => f.name);
+ return fieldsList.every((field) => fieldNames.includes(field));
+ },
+ (newArgs, lastArgs) => newArgs[0] === lastArgs[0]
+ ),
+ [dataViews]
+ );
};
diff --git a/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.test.tsx b/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.test.tsx
index 4c938d5e92d78..4a78cd8939921 100644
--- a/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.test.tsx
+++ b/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.test.tsx
@@ -58,9 +58,9 @@ const mockGetStorage = jest.fn();
const mockSetStorage = jest.fn();
const setQuery: jest.Mock = jest.fn();
const filebeatDataView = { id: '6f1eeb50-023d-11eb-bcb6-6ba0578012a9', title: 'filebeat-*' };
-const auditbeatDataView = { id: '28995490-023d-11eb-bcb6-6ba0578012a9', title: 'auditbeat-*' };
+const packetbeatDataView = { id: '28995490-023d-11eb-bcb6-6ba0578012a9', title: 'packetbeat-*' };
const mockSelector = {
- kibanaDataViews: [filebeatDataView, auditbeatDataView],
+ kibanaDataViews: [filebeatDataView, packetbeatDataView],
};
const embeddableValue = {
destroyed: false,
@@ -102,9 +102,9 @@ describe('EmbeddedMapComponent', () => {
setQuery.mockClear();
mockGetStorage.mockReturnValue(true);
jest.spyOn(redux, 'useSelector').mockReturnValue(mockSelector);
- mockUseSourcererDataView.mockReturnValue({ selectedPatterns: ['filebeat-*', 'packetbeat-*'] });
+ mockUseSourcererDataView.mockReturnValue({ selectedPatterns: ['filebeat-*', 'auditbeat-*'] });
mockCreateEmbeddable.mockResolvedValue(embeddableValue);
- mockUseIsFieldInIndexPattern.mockReturnValue(() => [true, true]);
+ mockUseIsFieldInIndexPattern.mockReturnValue(() => true);
});
afterEach(() => {
@@ -215,9 +215,9 @@ describe('EmbeddedMapComponent', () => {
});
test('On rerender with new selected patterns, selects existing Kibana data views that match any selected index pattern', async () => {
- mockUseSourcererDataView
- .mockReturnValueOnce({ selectedPatterns: ['filebeat-*', 'packetbeat-*'] })
- .mockReturnValue({ selectedPatterns: ['filebeat-*', 'auditbeat-*'] });
+ mockUseSourcererDataView.mockReturnValue({
+ selectedPatterns: ['filebeat-*', 'auditbeat-*'],
+ });
const { rerender } = render(
@@ -227,6 +227,9 @@ describe('EmbeddedMapComponent', () => {
const dataViewArg = (getLayerList as jest.Mock).mock.calls[0][0];
expect(dataViewArg).toEqual([filebeatDataView]);
});
+ mockUseSourcererDataView.mockReturnValue({
+ selectedPatterns: ['filebeat-*', 'packetbeat-*'],
+ });
rerender(
@@ -235,7 +238,7 @@ describe('EmbeddedMapComponent', () => {
await waitFor(() => {
// data view is updated with the returned embeddable.setLayerList callback, which is passesd getLayerList(dataViews)
const dataViewArg = (getLayerList as jest.Mock).mock.calls[1][0];
- expect(dataViewArg).toEqual([filebeatDataView, auditbeatDataView]);
+ expect(dataViewArg).toEqual([filebeatDataView, packetbeatDataView]);
});
});
});
diff --git a/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.tsx
index 14215000c5611..8e94720398d70 100644
--- a/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.tsx
+++ b/x-pack/plugins/security_solution/public/explore/network/components/embeddables/embedded_map.tsx
@@ -5,15 +5,17 @@
* 2.0.
*/
+// embedded map v2
+
import { EuiAccordion, EuiLink, EuiText } from '@elastic/eui';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { createHtmlPortalNode, InPortal } from 'react-reverse-portal';
import styled, { css } from 'styled-components';
-
import type { Filter, Query } from '@kbn/es-query';
import type { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import type { MapEmbeddable } from '@kbn/maps-plugin/public/embeddable';
+import { isEqual } from 'lodash/fp';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useIsFieldInIndexPattern } from '../../../containers/fields';
import { Loader } from '../../../../common/components/loader';
@@ -24,7 +26,7 @@ import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt';
import { MapToolTip } from './map_tool_tip/map_tool_tip';
import * as i18n from './translations';
import { useKibana } from '../../../../common/lib/kibana';
-import { getLayerList, getRequiredMapsFields } from './map_config';
+import { getLayerList } from './map_config';
import { sourcererSelectors } from '../../../../common/store/sourcerer';
import type { SourcererDataView } from '../../../../common/store/sourcerer/model';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
@@ -123,16 +125,7 @@ export const EmbeddedMapComponent = ({
const isFieldInIndexPattern = useIsFieldInIndexPattern();
const [mapDataViews, setMapDataViews] = useState([]);
-
- const availableDataViews = useMemo(() => {
- const dataViews = kibanaDataViews.filter((dataView) =>
- selectedPatterns.includes(dataView.title)
- );
- if (selectedPatterns.length > 0 && dataViews.length === 0) {
- setIsIndexError(true);
- }
- return dataViews;
- }, [kibanaDataViews, selectedPatterns]);
+ const [availableDataViews, setAvailableDataViews] = useState([]);
useEffect(() => {
let canceled = false;
@@ -140,9 +133,7 @@ export const EmbeddedMapComponent = ({
const fetchData = async () => {
try {
const apiResponse = await Promise.all(
- availableDataViews.map(async ({ title }) =>
- isFieldInIndexPattern(title, getRequiredMapsFields(title))
- )
+ availableDataViews.map(async ({ title }) => isFieldInIndexPattern(title))
);
// ensures only index patterns with maps fields are passed
const goodDataViews = availableDataViews.filter((_, i) => apiResponse[i] ?? false);
@@ -165,6 +156,16 @@ export const EmbeddedMapComponent = ({
};
}, [addError, availableDataViews, isFieldInIndexPattern]);
+ useEffect(() => {
+ const dataViews = kibanaDataViews.filter((dataView) =>
+ selectedPatterns.includes(dataView.title)
+ );
+ if (selectedPatterns.length > 0 && dataViews.length === 0) {
+ setIsIndexError(true);
+ }
+ setAvailableDataViews((prevViews) => (isEqual(prevViews, dataViews) ? prevViews : dataViews));
+ }, [kibanaDataViews, selectedPatterns]);
+
// This portalNode provided by react-reverse-portal allows us re-parent the MapToolTip within our
// own component tree instead of the embeddables (default). This is necessary to have access to
// the Redux store, theme provider, etc, which is required to register and un-register the draggable
From 0188a895aa7f0021588d2da98f9d1b63bb0daa25 Mon Sep 17 00:00:00 2001
From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Date: Mon, 6 Feb 2023 16:06:27 -0500
Subject: [PATCH 017/134] skip failing test suite (#134517)
---
.../reporting_and_security/usage/api_counters.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts
index 93b7d4d71fafb..6995436c47c50 100644
--- a/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts
+++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts
@@ -17,7 +17,8 @@ export default function ({ getService }: FtrProviderContext) {
const usageAPI = getService('usageAPI');
const reportingAPI = getService('reportingAPI');
- describe(`Usage Counters`, () => {
+ // Failing: See https://github.com/elastic/kibana/issues/134517
+ describe.skip(`Usage Counters`, () => {
before(async () => {
await esArchiver.emptyKibanaIndex();
await reportingAPI.initEcommerce();
From c37e2542cf2cd2f2b4d1258b7d78f762e9176230 Mon Sep 17 00:00:00 2001
From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Date: Mon, 6 Feb 2023 16:12:19 -0500
Subject: [PATCH 018/134] skip failing test suite (#149942)
---
.../reporting_and_security/usage/api_counters.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts
index 6995436c47c50..e490d88820756 100644
--- a/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts
+++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/api_counters.ts
@@ -18,6 +18,7 @@ export default function ({ getService }: FtrProviderContext) {
const reportingAPI = getService('reportingAPI');
// Failing: See https://github.com/elastic/kibana/issues/134517
+ // Failing: See https://github.com/elastic/kibana/issues/149942
describe.skip(`Usage Counters`, () => {
before(async () => {
await esArchiver.emptyKibanaIndex();
From b37eefce2d1988015f309de29e2d3971d23defa1 Mon Sep 17 00:00:00 2001
From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com>
Date: Mon, 6 Feb 2023 22:12:33 +0100
Subject: [PATCH 019/134] Make "for each alert" option default for all the rule
types (#150344)
As some of the users are using a template to fill out the message input
with `for each alert` params, having `summary of alerts` option default
causes some problems.
Therefore, this PR intends to make "for each alert" option default for
all the rule types.
---
.../action_connector_form/action_form.tsx | 10 ++--
.../action_notify_when.test.tsx | 19 +++----
.../action_notify_when.tsx | 51 ++-----------------
.../action_type_form.test.tsx | 14 ++---
.../sections/rule_form/rule_reducer.ts | 4 +-
.../constants/action_frequency_types.ts | 8 +--
6 files changed, 26 insertions(+), 80 deletions(-)
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx
index 900d2339ec87e..cc2810897bb08 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx
@@ -35,11 +35,7 @@ import { ActionTypeForm } from './action_type_form';
import { AddConnectorInline } from './connector_add_inline';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
-import {
- DEFAULT_FREQUENCY_WITH_SUMMARY,
- DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
- VIEW_LICENSE_OPTIONS_LINK,
-} from '../../../common/constants';
+import { DEFAULT_FREQUENCY, VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { ConnectorAddModal } from '.';
import { suspendedComponentWithProps } from '../../lib/suspended_component_with_props';
@@ -224,7 +220,7 @@ export const ActionForm = ({
actionTypeId: actionTypeModel.id,
group: defaultActionGroupId,
params: {},
- frequency: hasSummary ? DEFAULT_FREQUENCY_WITH_SUMMARY : DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
+ frequency: DEFAULT_FREQUENCY,
});
setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1);
}
@@ -236,7 +232,7 @@ export const ActionForm = ({
actionTypeId: actionTypeModel.id,
group: defaultActionGroupId,
params: {},
- frequency: hasSummary ? DEFAULT_FREQUENCY_WITH_SUMMARY : DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
+ frequency: DEFAULT_FREQUENCY,
});
setActionIdByIndex(actions.length.toString(), actions.length - 1);
setEmptyActionsIds([...emptyActionsIds, actions.length.toString()]);
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx
index 8fd286784824c..8186a7edbf09d 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx
@@ -12,14 +12,11 @@ import { act } from 'react-dom/test-utils';
import { RuleAction } from '../../../types';
import { ActionNotifyWhen } from './action_notify_when';
import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
-import {
- DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
- DEFAULT_FREQUENCY_WITH_SUMMARY,
-} from '../../../common/constants';
+import { DEFAULT_FREQUENCY } from '../../../common/constants';
describe('action_notify_when', () => {
async function setup(
- frequency: RuleAction['frequency'] = DEFAULT_FREQUENCY_WITH_SUMMARY,
+ frequency: RuleAction['frequency'] = DEFAULT_FREQUENCY,
hasSummary: boolean = true
) {
const wrapper = mountWithIntl(
@@ -50,15 +47,15 @@ describe('action_notify_when', () => {
'[data-test-subj="summaryOrPerRuleSelect"]'
);
expect(summaryOrPerRuleSelect.exists()).toBeTruthy();
- expect(summaryOrPerRuleSelect.first().props()['aria-label']).toEqual('Summary of alerts');
+ expect(summaryOrPerRuleSelect.first().props()['aria-label']).toEqual('For each alert');
const notifyWhenSelect = wrapperDefault.find('[data-test-subj="notifyWhenSelect"]');
expect(notifyWhenSelect.exists()).toBeTruthy();
expect((notifyWhenSelect.first().props() as EuiSuperSelectProps<''>).valueOfSelected).toEqual(
- RuleNotifyWhen.ACTIVE
+ RuleNotifyWhen.CHANGE
);
}
- const wrapperForEach = await setup(DEFAULT_FREQUENCY_WITHOUT_SUMMARY);
+ const wrapperForEach = await setup(DEFAULT_FREQUENCY);
{
const summaryOrPerRuleSelect = wrapperForEach.find(
'[data-test-subj="summaryOrPerRuleSelect"]'
@@ -73,7 +70,7 @@ describe('action_notify_when', () => {
);
}
const wrapperSummaryThrottle = await setup({
- ...DEFAULT_FREQUENCY_WITH_SUMMARY,
+ ...DEFAULT_FREQUENCY,
throttle: '5h',
notifyWhen: RuleNotifyWhen.THROTTLE,
});
@@ -82,7 +79,7 @@ describe('action_notify_when', () => {
'[data-test-subj="summaryOrPerRuleSelect"]'
);
expect(summaryOrPerRuleSelect.exists()).toBeTruthy();
- expect(summaryOrPerRuleSelect.first().props()['aria-label']).toEqual('Summary of alerts');
+ expect(summaryOrPerRuleSelect.first().props()['aria-label']).toEqual('For each alert');
const notifyWhenSelect = wrapperSummaryThrottle.find('[data-test-subj="notifyWhenSelect"]');
expect(notifyWhenSelect.exists()).toBeTruthy();
@@ -99,7 +96,7 @@ describe('action_notify_when', () => {
});
it('hides the summary selector when hasSummary is false', async () => {
- const wrapper = await setup(DEFAULT_FREQUENCY_WITHOUT_SUMMARY, false);
+ const wrapper = await setup(DEFAULT_FREQUENCY, false);
const summaryOrPerRuleSelect = wrapper.find('[data-test-subj="summaryOrPerRuleSelect"]');
expect(summaryOrPerRuleSelect.exists()).toBeFalsy();
});
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx
index 5735568df592d..5295fcc08131d 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx
@@ -29,10 +29,7 @@ import { some, filter, map } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { getTimeOptions } from '../../../common/lib/get_time_options';
import { RuleNotifyWhenType, RuleAction } from '../../../types';
-import {
- DEFAULT_FREQUENCY_WITH_SUMMARY,
- DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
-} from '../../../common/constants';
+import { DEFAULT_FREQUENCY } from '../../../common/constants';
export const NOTIFY_WHEN_OPTIONS: Array> = [
{
@@ -135,7 +132,7 @@ interface ActionNotifyWhenProps {
export const ActionNotifyWhen = ({
hasSummary,
- frequency = hasSummary ? DEFAULT_FREQUENCY_WITH_SUMMARY : DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
+ frequency = DEFAULT_FREQUENCY,
throttle,
throttleUnit,
onNotifyWhenChange,
@@ -146,32 +143,7 @@ export const ActionNotifyWhen = ({
}: ActionNotifyWhenProps) => {
const [showCustomThrottleOpts, setShowCustomThrottleOpts] = useState(false);
const [notifyWhenValue, setNotifyWhenValue] = useState(
- hasSummary
- ? DEFAULT_FREQUENCY_WITH_SUMMARY.notifyWhen
- : DEFAULT_FREQUENCY_WITHOUT_SUMMARY.notifyWhen
- );
-
- // Track whether the user has changed the notify when value from default. This is necessary because the
- // "default" notifyWhen value for summary: true is the second menu item for summary: false. We want the UX to be:
- // Case A
- // - User opens the form with summary: false, notifyWhen: CHANGE
- // - User switches to summary: true, necessitating a switch to notifyWhen: ACTIVE
- // - User doesn't touch notifyWhen: ACTIVE, switches back to summary: false. notifyWhen should switch to CHANGE, the 1st menu option
- // Case B
- // - User opens the form with summary: false, notifyWhen: ACTIVE (not the "default")
- // - User switches to summary: true
- // - User switches back to summary: false. notifyWhen stays ACTIVE
- // Case C
- // - User opens the form with summary: true, notifyWhen: ACTIVE (the "default")
- // - User doesn't change notifyWhen, just sets summary: false. notifyWhen should switch to CHANGE
- // Case D
- // - User opens the form with summary: true, notifyWhen: THROTTLE, or summary: false, notifyWhen: !CHANGE
- // - When user changes summary, leave notifyWhen unchanged
- const [notifyWhenValueChangedFromDefault, setNotifyWhenValueChangedFromDefault] = useState(
- // Check if the initial notifyWhen value is different from the default value for its summary type
- frequency.summary
- ? frequency.notifyWhen !== DEFAULT_FREQUENCY_WITH_SUMMARY.notifyWhen
- : frequency.notifyWhen !== DEFAULT_FREQUENCY_WITHOUT_SUMMARY.notifyWhen
+ DEFAULT_FREQUENCY.notifyWhen
);
const [summaryMenuOpen, setSummaryMenuOpen] = useState(false);
@@ -193,7 +165,6 @@ export const ActionNotifyWhen = ({
(newValue: RuleNotifyWhenType) => {
onNotifyWhenChange(newValue);
setNotifyWhenValue(newValue);
- setNotifyWhenValueChangedFromDefault(true);
// Calling onNotifyWhenChange and onThrottleChange at the same time interferes with the React state lifecycle
// so wait for onNotifyWhenChange to process before calling onThrottleChange
setTimeout(
@@ -213,24 +184,10 @@ export const ActionNotifyWhen = ({
onSummaryChange(summary);
setSummaryMenuOpen(false);
if (summary && frequency.notifyWhen === RuleNotifyWhen.CHANGE) {
- // Call onNotifyWhenChange DIRECTLY to bypass setNotifyWhenValueChangedFromDefault
onNotifyWhenChange(RuleNotifyWhen.ACTIVE);
- // In cases like this:
- // 1. User opens form with notifyWhen: THROTTLE
- // 2. User sets notifyWhen: CHANGE, notifyWhenValueChangedFromDefault is now true
- // 3. User sets summary: true, notifyWhen gets set to CHANGE
- // 4. User sets summary: false, notifyWhen should probably get set back to CHANGE
- // To make step 4 possible, we have to reset notifyWhenValueChangedFromDefault:
- setNotifyWhenValueChangedFromDefault(false);
- } else if (
- !summary &&
- frequency.notifyWhen === RuleNotifyWhen.ACTIVE &&
- !notifyWhenValueChangedFromDefault
- ) {
- onNotifyWhenChange(RuleNotifyWhen.CHANGE);
}
},
- [onSummaryChange, frequency.notifyWhen, onNotifyWhenChange, notifyWhenValueChangedFromDefault]
+ [onSummaryChange, frequency.notifyWhen, onNotifyWhenChange]
);
const summaryOptions = useMemo(
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx
index 445e983d7fb02..73f0810d4f98d 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx
@@ -20,11 +20,9 @@ import { act } from 'react-dom/test-utils';
import { EuiFieldText } from '@elastic/eui';
import { I18nProvider, __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render, waitFor, screen } from '@testing-library/react';
-import {
- DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
- DEFAULT_FREQUENCY_WITH_SUMMARY,
-} from '../../../common/constants';
+import { DEFAULT_FREQUENCY } from '../../../common/constants';
import { transformActionVariables } from '../../lib/action_variables';
+import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
jest.mock('../../../common/lib/kibana');
const actionTypeRegistry = actionTypeRegistryMock.create();
@@ -312,7 +310,7 @@ describe('action_type_form', () => {
actionTypeId: '.pagerduty',
group: 'default',
params: {},
- frequency: DEFAULT_FREQUENCY_WITHOUT_SUMMARY,
+ frequency: DEFAULT_FREQUENCY,
};
const wrapper = render(
@@ -320,7 +318,11 @@ describe('action_type_form', () => {
index: 1,
actionItem,
setActionFrequencyProperty: () => {
- actionItem.frequency = DEFAULT_FREQUENCY_WITH_SUMMARY;
+ actionItem.frequency = {
+ notifyWhen: RuleNotifyWhen.ACTIVE,
+ throttle: null,
+ summary: true,
+ };
},
})}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts
index 01f14ba6ff789..55ce45d592d00 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts
+++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts
@@ -10,7 +10,7 @@ import { isEqual } from 'lodash';
import { Reducer } from 'react';
import { RuleActionParam, IntervalSchedule } from '@kbn/alerting-plugin/common';
import { Rule, RuleAction } from '../../../types';
-import { DEFAULT_FREQUENCY_WITHOUT_SUMMARY } from '../../../common/constants';
+import { DEFAULT_FREQUENCY } from '../../../common/constants';
export type InitialRule = Partial &
Pick;
@@ -201,7 +201,7 @@ export const ruleReducer = (
const updatedAction = {
...oldAction,
frequency: {
- ...(oldAction.frequency ?? DEFAULT_FREQUENCY_WITHOUT_SUMMARY),
+ ...(oldAction.frequency ?? DEFAULT_FREQUENCY),
[key]: value,
},
};
diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/action_frequency_types.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/action_frequency_types.ts
index 2aad24fe63846..7ebfc502ed07d 100644
--- a/x-pack/plugins/triggers_actions_ui/public/common/constants/action_frequency_types.ts
+++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/action_frequency_types.ts
@@ -7,13 +7,7 @@
import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
-export const DEFAULT_FREQUENCY_WITH_SUMMARY = {
- notifyWhen: RuleNotifyWhen.ACTIVE,
- throttle: null,
- summary: true,
-};
-
-export const DEFAULT_FREQUENCY_WITHOUT_SUMMARY = {
+export const DEFAULT_FREQUENCY = {
notifyWhen: RuleNotifyWhen.CHANGE,
throttle: null,
summary: false,
From 01a18df4365d412f5d05f98beafd72c0f3feabe9 Mon Sep 17 00:00:00 2001
From: Jeramy Soucy
Date: Mon, 6 Feb 2023 16:31:35 -0500
Subject: [PATCH 020/134] [Docs] Documents constraints of space id in create
space API (#150379)
closes #150311
Adds wording to clarify that the space ID must be lowercase
alphanumeric, but can include underscores and hyphens. Previously this
restriction was not documented, but if these requirements are not met
the API will respond with a 400.
---
docs/api/spaces-management/post.asciidoc | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/api/spaces-management/post.asciidoc b/docs/api/spaces-management/post.asciidoc
index 28d60caa0d333..035fe897da251 100644
--- a/docs/api/spaces-management/post.asciidoc
+++ b/docs/api/spaces-management/post.asciidoc
@@ -15,7 +15,7 @@ experimental[] Create a {kib} space.
==== Request body
`id`::
- (Required, string) The space ID that is part of the Kibana URL when inside the space. You are unable to change the ID with the update operation.
+ (Required, string) The space ID that is part of the Kibana URL when inside the space. Space IDs are limited to lowercase alphanumeric, underscore, and hyphen characters (a-z, 0-9, '_', and '-'). You are unable to change the ID with the update operation.
`name`::
(Required, string) The display name for the space.
From 2ff017ed77e474b5ee2c0114dc6e9315423d6134 Mon Sep 17 00:00:00 2001
From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com>
Date: Mon, 6 Feb 2023 21:41:11 +0000
Subject: [PATCH 021/134] [Security Solution][Alerts] improves IM rule memory
usage and performance (#149208)
## Summary
- addresses https://github.com/elastic/kibana/issues/148821
- instead of loading all threats result in memory and also creating
object map from it(which doubles memory consumption)
it creates signals map right straight from threat results(in
`getSignalsMatchesFromThreatIndex`) and doesn't keep all threats in
memory anymore
- because threats are not kept in memory anymore, additional request
introduced that fetches threats before enrichments, based on signals map
created on the previous stage
- [performance
measurements](https://github.com/elastic/kibana/issues/148821#issuecomment-1410242932)
- additional issues opened while working on current PR:
- https://github.com/elastic/kibana/issues/150041
- https://github.com/elastic/kibana/issues/150038
- reduces further number of matched threats in alert to 200, to prevent
Kibana browser tab becoming unresponsive
### Checklist
Delete any items that are not applicable to this PR.
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
.../threat_mapping/build_threat_enrichment.ts | 29 +-
.../threat_mapping/create_event_signal.ts | 50 +-
.../enrich_signal_threat_matches.test.ts | 451 ++++--------------
.../enrich_signal_threat_matches.ts | 197 ++------
.../get_signals_map_from_threat_index.mock.ts | 33 ++
.../get_signals_map_from_threat_index.test.ts | 305 ++++++++++++
.../get_signals_map_from_threat_index.ts | 126 +++++
.../signals/threat_mapping/get_threat_list.ts | 24 -
.../threat_enrichment_factory.test.ts | 108 +++++
.../threat_enrichment_factory.ts | 76 +++
10 files changed, 823 insertions(+), 576 deletions(-)
create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.mock.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.test.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.test.ts
create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.ts
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts
index 32fe9b8dbe8ce..18cf4240d0b18 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_enrichment.ts
@@ -6,13 +6,11 @@
*/
import type { SignalsEnrichment } from '../types';
-import {
- enrichSignalThreatMatches,
- getSignalMatchesFromThreatList,
-} from './enrich_signal_threat_matches';
import type { BuildThreatEnrichmentOptions } from './types';
import { buildThreatMappingFilter } from './build_threat_mapping_filter';
-import { getAllThreatListHits } from './get_threat_list';
+import { getSignalsQueryMapFromThreatIndex } from './get_signals_map_from_threat_index';
+
+import { threatEnrichmentFactory } from './threat_enrichment_factory';
// we do want to make extra requests to the threat index to get enrichments from all threats
// previously we were enriched alerts only from `currentThreatList` but not all threats
@@ -42,7 +40,7 @@ export const buildThreatEnrichment = ({
},
});
- const threatListHits = await getAllThreatListHits({
+ const threatSearchParams = {
esClient: services.scopedClusterClient.asCurrentUser,
threatFilters: [...threatFilters, threatFiltersFromEvents],
query: threatQuery,
@@ -58,15 +56,20 @@ export const buildThreatEnrichment = ({
runtimeMappings,
listClient,
exceptionFilter,
- });
+ };
- const signalMatches = getSignalMatchesFromThreatList(threatListHits);
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams,
+ eventsCount: signals.length,
+ });
- return enrichSignalThreatMatches(
- signals,
- () => Promise.resolve(threatListHits),
+ const enrichment = threatEnrichmentFactory({
+ signalsQueryMap,
threatIndicatorPath,
- signalMatches
- );
+ threatFilters,
+ threatSearchParams,
+ });
+
+ return enrichment(signals);
};
};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts
index 598730c627185..c006ab528b3c5 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts
@@ -10,18 +10,14 @@ import { getFilter } from '../get_filter';
import { searchAfterAndBulkCreate } from '../search_after_bulk_create';
import { buildReasonMessageForThreatMatchAlert } from '../reason_formatters';
import type { CreateEventSignalOptions } from './types';
-import type { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types';
-import { getAllThreatListHits } from './get_threat_list';
-import {
- enrichSignalThreatMatches,
- getSignalMatchesFromThreatList,
-} from './enrich_signal_threat_matches';
+import type { SearchAfterAndBulkCreateReturnType } from '../types';
+import { getSignalsQueryMapFromThreatIndex } from './get_signals_map_from_threat_index';
+
+import { threatEnrichmentFactory } from './threat_enrichment_factory';
import { getSignalValueMap } from './utils';
export const createEventSignal = async ({
- alertId,
bulkCreate,
- completeRule,
currentResult,
currentEventList,
eventsTelemetry,
@@ -29,7 +25,6 @@ export const createEventSignal = async ({
inputIndex,
language,
listClient,
- outputIndex,
query,
ruleExecutionLogger,
savedId,
@@ -69,7 +64,7 @@ export const createEventSignal = async ({
);
return currentResult;
} else {
- const threatListHits = await getAllThreatListHits({
+ const threatSearchParams = {
esClient: services.scopedClusterClient.asCurrentUser,
threatFilters: [...threatFilters, threatFiltersFromEvents],
query: threatQuery,
@@ -77,7 +72,7 @@ export const createEventSignal = async ({
index: threatIndex,
ruleExecutionLogger,
threatListConfig: {
- _source: [`${threatIndicatorPath}.*`, 'threat.feed.*', ...threatMatchedFields.threat],
+ _source: threatMatchedFields.threat,
fields: undefined,
},
pitId: threatPitId,
@@ -85,15 +80,15 @@ export const createEventSignal = async ({
runtimeMappings,
listClient,
exceptionFilter,
- });
-
- const signalMatches = getSignalMatchesFromThreatList(
- threatListHits,
- getSignalValueMap({ eventList: currentEventList, threatMatchedFields })
- );
+ };
- const ids = signalMatches.map((item) => item.signalId);
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams,
+ eventsCount: currentEventList.length,
+ signalValueMap: getSignalValueMap({ eventList: currentEventList, threatMatchedFields }),
+ });
+ const ids = Array.from(signalsQueryMap.keys());
const indexFilter = {
query: {
bool: {
@@ -115,22 +110,19 @@ export const createEventSignal = async ({
exceptionFilter,
});
- ruleExecutionLogger.debug(
- `${ids?.length} matched signals found from ${threatListHits.length} indicators`
- );
+ ruleExecutionLogger.debug(`${ids?.length} matched signals found`);
- const threatEnrichment = (signals: SignalSourceHit[]): Promise =>
- enrichSignalThreatMatches(
- signals,
- () => Promise.resolve(threatListHits),
- threatIndicatorPath,
- signalMatches
- );
+ const enrichment = threatEnrichmentFactory({
+ signalsQueryMap,
+ threatIndicatorPath,
+ threatFilters,
+ threatSearchParams,
+ });
const result = await searchAfterAndBulkCreate({
buildReasonMessage: buildReasonMessageForThreatMatchAlert,
bulkCreate,
- enrichment: threatEnrichment,
+ enrichment,
eventsTelemetry,
exceptionsList: unprocessedExceptions,
filter: esFilter,
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts
index 974b0e00a7ce4..2658c90720adb 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts
@@ -13,19 +13,11 @@ import type { SignalSourceHit } from '../types';
import { getThreatListItemMock } from './build_threat_mapping_filter.mock';
import {
buildEnrichments,
- enrichSignalThreatMatches,
groupAndMergeSignalMatches,
- getSignalMatchesFromThreatList,
- MAX_NUMBER_OF_SIGNAL_MATCHES,
+ enrichSignalThreatMatchesFromSignalsMap,
} from './enrich_signal_threat_matches';
import { getNamedQueryMock, getSignalHitMock } from './enrich_signal_threat_matches.mock';
-import type {
- GetMatchedThreats,
- ThreatListItem,
- ThreatMatchNamedQuery,
- SignalMatch,
-} from './types';
-import { encodeThreatMatchNamedQuery } from './utils';
+import type { ThreatListItem, ThreatMatchNamedQuery } from './types';
describe('groupAndMergeSignalMatches', () => {
it('returns an empty array if there are no signals', () => {
@@ -481,11 +473,10 @@ describe('buildEnrichments', () => {
});
});
-describe('enrichSignalThreatMatches', () => {
- let getMatchedThreats: GetMatchedThreats;
- let matchedQuery: string;
+describe('enrichSignalThreatMatchesFromSignalsMap', () => {
+ let getMatchedThreats: () => Promise;
let indicatorPath: string;
- let signalMatches: SignalMatch[];
+ let signalsMap = new Map();
beforeEach(() => {
indicatorPath = 'threat.indicator';
@@ -500,56 +491,67 @@ describe('enrichSignalThreatMatches', () => {
},
}),
];
- matchedQuery = encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- id: '123',
- index: 'indicator_index',
- field: 'event.domain',
- value: 'threat.indicator.domain',
- })
- );
- signalMatches = [
- {
- signalId: '_id',
- queries: [
- getNamedQueryMock({
+ signalsMap = new Map([
+ [
+ 'source-id',
+ [
+ {
id: '123',
index: 'indicator_index',
field: 'event.domain',
value: 'threat.indicator.domain',
- }),
+ },
],
- },
- ];
+ ],
+ ]);
});
it('performs no enrichment if there are no signals', async () => {
const signals: SignalSourceHit[] = [];
- const enrichedSignals = await enrichSignalThreatMatches(
+ const enrichedSignals = await enrichSignalThreatMatchesFromSignalsMap(
signals,
getMatchedThreats,
indicatorPath,
- []
+ new Map()
);
expect(enrichedSignals).toEqual([]);
});
+ it('performs no enrichment if signalsMap empty', async () => {
+ const signalHit = getSignalHitMock({
+ _source: {
+ '@timestamp': 'mocked',
+ event: { category: 'malware', domain: 'domain_1' },
+ threat: { enrichments: [{ existing: 'indicator' }] },
+ },
+ });
+ const signals: SignalSourceHit[] = [signalHit];
+ const enrichedSignals = await enrichSignalThreatMatchesFromSignalsMap(
+ signals,
+ getMatchedThreats,
+ indicatorPath,
+ new Map()
+ );
+
+ expect(enrichedSignals).toEqual([signalHit]);
+ });
+
it('preserves existing threat.enrichments objects on signals', async () => {
const signalHit = getSignalHitMock({
+ _id: 'source-id',
_source: {
'@timestamp': 'mocked',
event: { category: 'malware', domain: 'domain_1' },
threat: { enrichments: [{ existing: 'indicator' }] },
},
- matched_queries: [matchedQuery],
});
const signals: SignalSourceHit[] = [signalHit];
- const enrichedSignals = await enrichSignalThreatMatches(
+ const enrichedSignals = await enrichSignalThreatMatchesFromSignalsMap(
signals,
getMatchedThreats,
indicatorPath,
- signalMatches
+ signalsMap
);
const [enrichedHit] = enrichedSignals;
const enrichments = get(enrichedHit._source, ENRICHMENT_DESTINATION_PATH);
@@ -577,14 +579,14 @@ describe('enrichSignalThreatMatches', () => {
it('provides only match data if the matched threat cannot be found', async () => {
getMatchedThreats = async () => [];
const signalHit = getSignalHitMock({
- matched_queries: [matchedQuery],
+ _id: 'source-id',
});
const signals: SignalSourceHit[] = [signalHit];
- const enrichedSignals = await enrichSignalThreatMatches(
+ const enrichedSignals = await enrichSignalThreatMatchesFromSignalsMap(
signals,
getMatchedThreats,
indicatorPath,
- signalMatches
+ signalsMap
);
const [enrichedHit] = enrichedSignals;
const enrichments = get(enrichedHit._source, ENRICHMENT_DESTINATION_PATH);
@@ -606,6 +608,7 @@ describe('enrichSignalThreatMatches', () => {
it('preserves an existing threat.enrichments object on signals', async () => {
const signalHit = getSignalHitMock({
+ _id: 'source-id',
_source: {
'@timestamp': 'mocked',
event: { category: 'virus', domain: 'domain_1' },
@@ -616,14 +619,13 @@ describe('enrichSignalThreatMatches', () => {
],
},
},
- matched_queries: [matchedQuery],
});
const signals: SignalSourceHit[] = [signalHit];
- const enrichedSignals = await enrichSignalThreatMatches(
+ const enrichedSignals = await enrichSignalThreatMatchesFromSignalsMap(
signals,
getMatchedThreats,
indicatorPath,
- signalMatches
+ signalsMap
);
const [enrichedHit] = enrichedSignals;
const enrichments = get(enrichedHit._source, ENRICHMENT_DESTINATION_PATH);
@@ -656,15 +658,28 @@ describe('enrichSignalThreatMatches', () => {
it('throws an error if threat is neither an object nor undefined', async () => {
const signalHit = getSignalHitMock({
_source: { '@timestamp': 'mocked', threat: 'whoops' },
- matched_queries: [matchedQuery],
});
const signals: SignalSourceHit[] = [signalHit];
await expect(() =>
- enrichSignalThreatMatches(signals, getMatchedThreats, indicatorPath, signalMatches)
+ enrichSignalThreatMatchesFromSignalsMap(signals, getMatchedThreats, indicatorPath, signalsMap)
).rejects.toThrowError('Expected threat field to be an object, but found: whoops');
});
it('enriches from a configured indicator path, if specified', async () => {
+ signalsMap = new Map([
+ [
+ 'source-id',
+ [
+ {
+ id: '123',
+ index: 'custom_index',
+ field: 'event.domain',
+ value: 'threat.indicator.domain',
+ },
+ ],
+ ],
+ ]);
+
getMatchedThreats = async () => [
getThreatListItemMock({
_id: '123',
@@ -679,27 +694,20 @@ describe('enrichSignalThreatMatches', () => {
},
}),
];
- const namedQuery = getNamedQueryMock({
- id: '123',
- index: 'custom_index',
- field: 'event.domain',
- value: 'custom_threat.custom_indicator.domain',
- });
- matchedQuery = encodeThreatMatchNamedQuery(namedQuery);
const signalHit = getSignalHitMock({
+ _id: 'source-id',
_source: {
event: {
domain: 'domain_1',
},
},
- matched_queries: [matchedQuery],
});
const signals: SignalSourceHit[] = [signalHit];
- const enrichedSignals = await enrichSignalThreatMatches(
+ const enrichedSignals = await enrichSignalThreatMatchesFromSignalsMap(
signals,
getMatchedThreats,
'custom_threat.custom_indicator',
- [{ signalId: '_id', queries: [namedQuery] }]
+ signalsMap
);
const [enrichedHit] = enrichedSignals;
const enrichments = get(enrichedHit._source, ENRICHMENT_DESTINATION_PATH);
@@ -724,6 +732,26 @@ describe('enrichSignalThreatMatches', () => {
});
it('merges duplicate matched signals into a single signal with multiple enrichments', async () => {
+ signalsMap = new Map([
+ [
+ 'source-id',
+ [
+ {
+ id: '123',
+ index: 'indicator_index',
+ field: 'event.domain',
+ value: 'threat.indicator.domain',
+ },
+ {
+ id: '456',
+ index: 'other_custom_index',
+ field: 'event.other',
+ value: 'threat.indicator.domain',
+ },
+ ],
+ ],
+ ]);
+
getMatchedThreats = async () => [
getThreatListItemMock({
_id: '123',
@@ -741,50 +769,29 @@ describe('enrichSignalThreatMatches', () => {
}),
];
const signalHit = getSignalHitMock({
- _id: 'signal123',
+ _id: 'source-id',
_source: {
event: {
domain: 'domain_1',
other: 'test_val',
},
},
- matched_queries: [matchedQuery],
- });
- const otherMatchQuery = getNamedQueryMock({
- id: '456',
- index: 'other_custom_index',
- field: 'event.other',
- value: 'threat.indicator.domain',
});
const otherSignalHit = getSignalHitMock({
- _id: 'signal123',
+ _id: 'source-id',
_source: {
event: {
domain: 'domain_1',
other: 'test_val',
},
},
- matched_queries: [encodeThreatMatchNamedQuery(otherMatchQuery)],
});
const signals: SignalSourceHit[] = [signalHit, otherSignalHit];
- const enrichedSignals = await enrichSignalThreatMatches(
+ const enrichedSignals = await enrichSignalThreatMatchesFromSignalsMap(
signals,
getMatchedThreats,
indicatorPath,
- [
- {
- signalId: 'signal123',
- queries: [
- getNamedQueryMock({
- id: '123',
- index: 'indicator_index',
- field: 'event.domain',
- value: 'threat.indicator.domain',
- }),
- otherMatchQuery,
- ],
- },
- ]
+ signalsMap
);
expect(enrichedSignals).toHaveLength(1);
@@ -825,291 +832,3 @@ describe('enrichSignalThreatMatches', () => {
]);
});
});
-
-describe('getSignalMatchesFromThreatList', () => {
- it('return empty array if there no threat indicators', () => {
- const signalMatches = getSignalMatchesFromThreatList();
- expect(signalMatches).toEqual([]);
- });
-
- it("return empty array if threat indicators doesn't have matched query", () => {
- const signalMatches = getSignalMatchesFromThreatList([getThreatListItemMock()]);
- expect(signalMatches).toEqual([]);
- });
-
- it('return signal matches from threat indicators', () => {
- const signalMatches = getSignalMatchesFromThreatList([
- getThreatListItemMock({
- _id: 'threatId',
- matched_queries: [
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- id: 'signalId1',
- index: 'source_index',
- value: 'threat.indicator.domain',
- field: 'event.domain',
- })
- ),
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- id: 'signalId2',
- index: 'source_index',
- value: 'threat.indicator.domain',
- field: 'event.domain',
- })
- ),
- ],
- }),
- ]);
-
- const queries = [
- {
- field: 'event.domain',
- value: 'threat.indicator.domain',
- index: 'threat_index',
- id: 'threatId',
- queryType: 'mq',
- },
- ];
-
- expect(signalMatches).toEqual([
- {
- signalId: 'signalId1',
- queries,
- },
- {
- signalId: 'signalId2',
- queries,
- },
- ]);
- });
-
- it('return empty array for terms query if there no signalValueMap', () => {
- const signalMatches = getSignalMatchesFromThreatList([
- getThreatListItemMock({
- _id: 'threatId',
- matched_queries: [
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- value: 'threat.indicator.domain',
- field: 'event.domain',
- queryType: 'tq',
- })
- ),
- ],
- }),
- ]);
-
- expect(signalMatches).toEqual([]);
- });
-
- it('return empty array for terms query if there wrong value in threat indicator', () => {
- const threat = getThreatListItemMock({
- _id: 'threatId',
- matched_queries: [
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- value: 'threat.indicator.domain',
- field: 'event.domain',
- queryType: 'tq',
- })
- ),
- ],
- });
-
- threat._source = {
- ...threat._source,
- threat: {
- indicator: {
- domain: { a: 'b' },
- },
- },
- };
-
- const signalValueMap = {
- 'event.domain': {
- domain_1: ['signalId1', 'signalId2'],
- },
- };
-
- const signalMatches = getSignalMatchesFromThreatList([threat], signalValueMap);
-
- expect(signalMatches).toEqual([]);
- });
-
- it('return signal matches from threat indicators for termsQuery', () => {
- const threat = getThreatListItemMock({
- _id: 'threatId',
- matched_queries: [
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- value: 'threat.indicator.domain',
- field: 'event.domain',
- queryType: 'tq',
- })
- ),
- ],
- });
-
- threat._source = {
- ...threat._source,
- threat: {
- indicator: {
- domain: 'domain_1',
- },
- },
- };
-
- const signalValueMap = {
- 'event.domain': {
- domain_1: ['signalId1', 'signalId2'],
- },
- };
-
- const signalMatches = getSignalMatchesFromThreatList([threat], signalValueMap);
-
- const queries = [
- {
- field: 'event.domain',
- value: 'threat.indicator.domain',
- index: 'threat_index',
- id: 'threatId',
- queryType: 'tq',
- },
- ];
-
- expect(signalMatches).toEqual([
- {
- signalId: 'signalId1',
- queries,
- },
- {
- signalId: 'signalId2',
- queries,
- },
- ]);
- });
-
- it('return signal matches from threat indicators which has array values for termsQuery', () => {
- const threat = getThreatListItemMock({
- _id: 'threatId',
- matched_queries: [
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- value: 'threat.indicator.domain',
- field: 'event.domain',
- queryType: 'tq',
- })
- ),
- ],
- });
-
- threat._source = {
- ...threat._source,
- threat: {
- indicator: {
- domain: ['domain_3', 'domain_1', 'domain_2'],
- },
- },
- };
-
- const signalValueMap = {
- 'event.domain': {
- domain_1: ['signalId1'],
- domain_2: ['signalId2'],
- },
- };
-
- const signalMatches = getSignalMatchesFromThreatList([threat], signalValueMap);
-
- const queries = [
- {
- field: 'event.domain',
- value: 'threat.indicator.domain',
- index: 'threat_index',
- id: 'threatId',
- queryType: 'tq',
- },
- ];
-
- expect(signalMatches).toEqual([
- {
- signalId: 'signalId1',
- queries,
- },
- {
- signalId: 'signalId2',
- queries,
- },
- ]);
- });
-
- it('merge signal matches if different threat indicators matched the same signal', () => {
- const matchedQuery = [
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- id: 'signalId',
- index: 'source_index',
- value: 'threat.indicator.domain',
- field: 'event.domain',
- })
- ),
- ];
- const signalMatches = getSignalMatchesFromThreatList([
- getThreatListItemMock({
- _id: 'threatId1',
- matched_queries: matchedQuery,
- }),
- getThreatListItemMock({
- _id: 'threatId2',
- matched_queries: matchedQuery,
- }),
- ]);
-
- const query = {
- field: 'event.domain',
- value: 'threat.indicator.domain',
- index: 'threat_index',
- id: 'threatId',
- queryType: 'mq',
- };
-
- expect(signalMatches).toEqual([
- {
- signalId: 'signalId',
- queries: [
- {
- ...query,
- id: 'threatId1',
- },
- {
- ...query,
- id: 'threatId2',
- },
- ],
- },
- ]);
- });
-
- it('limits number of signal matches to MAX_NUMBER_OF_SIGNAL_MATCHES', () => {
- const threatList = Array.from(Array(2000), (index) =>
- getThreatListItemMock({
- _id: `threatId-${index}`,
- matched_queries: [
- encodeThreatMatchNamedQuery(
- getNamedQueryMock({
- id: 'signalId1',
- index: 'source_index',
- value: 'threat.indicator.domain',
- field: 'event.domain',
- })
- ),
- ],
- })
- );
-
- const signalMatches = getSignalMatchesFromThreatList(threatList);
-
- expect(signalMatches[0].queries).toHaveLength(MAX_NUMBER_OF_SIGNAL_MATCHES);
- });
-});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts
index 3ce871283219d..28f43519f118f 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts
@@ -9,102 +9,9 @@ import { get, isObject } from 'lodash';
import { ENRICHMENT_TYPES, FEED_NAME_PATH } from '../../../../../common/cti/constants';
import type { SignalSourceHit } from '../types';
-import type {
- GetMatchedThreats,
- ThreatEnrichment,
- ThreatListItem,
- ThreatMatchNamedQuery,
- SignalMatch,
- SignalValuesMap,
- ThreatTermNamedQuery,
-} from './types';
-import { ThreatMatchQueryType } from './types';
-import { extractNamedQueries } from './utils';
-
-export const MAX_NUMBER_OF_SIGNAL_MATCHES = 1000;
-
-export const getSignalMatchesFromThreatList = (
- threatList: ThreatListItem[] = [],
- signalValueMap?: SignalValuesMap
-): SignalMatch[] => {
- const signalMap: { [key: string]: ThreatMatchNamedQuery[] } = {};
- const addSignalValueToMap = ({
- id,
- threatHit,
- query,
- }: {
- id: string;
- threatHit: ThreatListItem;
- query: ThreatMatchNamedQuery | ThreatTermNamedQuery;
- }) => {
- if (!signalMap[id]) {
- signalMap[id] = [];
- }
-
- // creating map of signal with large number of threats could lead to out of memory Kibana crash
- // large number of threats also can cause signals bulk create failure due too large payload (413)
- // large number of threats significantly slower alert details page render
- // so, its number is limited to MAX_NUMBER_OF_SIGNAL_MATCHES
- // more details https://github.com/elastic/kibana/issues/143595#issuecomment-1335433592
- if (signalMap[id].length >= MAX_NUMBER_OF_SIGNAL_MATCHES) {
- return;
- }
-
- signalMap[id].push({
- id: threatHit._id,
- index: threatHit._index,
- field: query.field,
- value: query.value,
- queryType: query.queryType,
- });
- };
- threatList.forEach((threatHit) =>
- extractNamedQueries(threatHit).forEach((query) => {
- const signalId = query.id;
-
- if (query.queryType === ThreatMatchQueryType.term) {
- const threatValue = get(threatHit?._source, query.value);
- let values;
- if (Array.isArray(threatValue)) {
- values = threatValue;
- } else {
- values = [threatValue];
- }
-
- values.forEach((value) => {
- if (value && signalValueMap) {
- const ids = signalValueMap[query.field][value?.toString()];
-
- ids?.forEach((id: string) => {
- addSignalValueToMap({
- id,
- threatHit,
- query,
- });
- });
- }
- });
- } else {
- if (!signalId) {
- return;
- }
-
- addSignalValueToMap({
- id: signalId,
- threatHit,
- query,
- });
- }
- })
- );
+import type { ThreatEnrichment, ThreatListItem, ThreatMatchNamedQuery } from './types';
- const signalMatches = Object.entries(signalMap).map(([key, value]) => ({
- signalId: key,
- queries: value,
- }));
-
- return signalMatches;
-};
+export const MAX_NUMBER_OF_SIGNAL_MATCHES = 200;
const getSignalId = (signal: SignalSourceHit): string => signal._id;
@@ -163,68 +70,70 @@ export const buildEnrichments = ({
};
});
-export const enrichSignalThreatMatches = async (
+const enrichSignalWithThreatMatches = (
+ signalHit: SignalSourceHit,
+ enrichmentsWithoutAtomic: { [key: string]: ThreatEnrichment[] }
+) => {
+ const threat = get(signalHit._source, 'threat') ?? {};
+ if (!isObject(threat)) {
+ throw new Error(`Expected threat field to be an object, but found: ${threat}`);
+ }
+ // We are not using ENRICHMENT_DESTINATION_PATH here because the code above
+ // and below make assumptions about its current value, 'threat.enrichments',
+ // and making this code dynamic on an arbitrary path would introduce several
+ // new issues.
+ const existingEnrichmentValue = get(signalHit._source, 'threat.enrichments') ?? [];
+ const existingEnrichments = [existingEnrichmentValue].flat(); // ensure enrichments is an array
+ const newEnrichmentsWithoutAtomic = enrichmentsWithoutAtomic[signalHit._id] ?? [];
+ const newEnrichments = newEnrichmentsWithoutAtomic.map((enrichment) => ({
+ ...enrichment,
+ matched: {
+ ...enrichment.matched,
+ atomic: get(signalHit._source, enrichment.matched.field),
+ },
+ }));
+
+ return {
+ ...signalHit,
+ _source: {
+ ...signalHit._source,
+ threat: {
+ ...threat,
+ enrichments: [...existingEnrichments, ...newEnrichments],
+ },
+ },
+ };
+};
+
+/**
+ * enrich signals threat matches using signalsMap(Map) that has match named query results
+ */
+export const enrichSignalThreatMatchesFromSignalsMap = async (
signals: SignalSourceHit[],
- getMatchedThreats: GetMatchedThreats,
+ getMatchedThreats: () => Promise,
indicatorPath: string,
- signalMatches: SignalMatch[]
+ signalsMap: Map
): Promise => {
if (signals.length === 0) {
- return signals;
+ return [];
}
const uniqueHits = groupAndMergeSignalMatches(signals);
+ const matchedThreats = await getMatchedThreats();
- const matchedThreatIds = [
- ...new Set(
- signalMatches
- .map((signalMatch) => signalMatch.queries)
- .flat()
- .map(({ id }) => id)
- ),
- ];
- const matchedThreats = await getMatchedThreats(matchedThreatIds);
-
- const enrichmentsWithoutAtomic: { [key: string]: ThreatEnrichment[] } = {};
- signalMatches.forEach((signalMatch) => {
- enrichmentsWithoutAtomic[signalMatch.signalId] = buildEnrichments({
+ const enrichmentsWithoutAtomic: Record = {};
+
+ uniqueHits.forEach((hit) => {
+ enrichmentsWithoutAtomic[hit._id] = buildEnrichments({
indicatorPath,
- queries: signalMatch.queries,
+ queries: signalsMap.get(hit._id) ?? [],
threats: matchedThreats,
});
});
- const enrichedSignals: SignalSourceHit[] = uniqueHits.map((signalHit, i) => {
- const threat = get(signalHit._source, 'threat') ?? {};
- if (!isObject(threat)) {
- throw new Error(`Expected threat field to be an object, but found: ${threat}`);
- }
- // We are not using ENRICHMENT_DESTINATION_PATH here because the code above
- // and below make assumptions about its current value, 'threat.enrichments',
- // and making this code dynamic on an arbitrary path would introduce several
- // new issues.
- const existingEnrichmentValue = get(signalHit._source, 'threat.enrichments') ?? [];
- const existingEnrichments = [existingEnrichmentValue].flat(); // ensure enrichments is an array
- const newEnrichmentsWithoutAtomic = enrichmentsWithoutAtomic[signalHit._id] ?? [];
- const newEnrichments = newEnrichmentsWithoutAtomic.map((enrichment) => ({
- ...enrichment,
- matched: {
- ...enrichment.matched,
- atomic: get(signalHit._source, enrichment.matched.field),
- },
- }));
-
- return {
- ...signalHit,
- _source: {
- ...signalHit._source,
- threat: {
- ...threat,
- enrichments: [...existingEnrichments, ...newEnrichments],
- },
- },
- };
- });
+ const enrichedSignals: SignalSourceHit[] = uniqueHits.map((signalHit) =>
+ enrichSignalWithThreatMatches(signalHit, enrichmentsWithoutAtomic)
+ );
return enrichedSignals;
};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.mock.ts
new file mode 100644
index 0000000000000..9fa23d25f2899
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.mock.ts
@@ -0,0 +1,33 @@
+/*
+ * 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 { elasticsearchServiceMock } from '@kbn/core/server/mocks';
+import type { GetThreatListOptions } from './types';
+import { getListClientMock } from '@kbn/lists-plugin/server/services/lists/list_client.mock';
+import { ruleExecutionLogMock } from '../../rule_monitoring/mocks';
+
+const esClient = elasticsearchServiceMock.createElasticsearchClient();
+const ruleExecutionLogger = ruleExecutionLogMock.forExecutors.create();
+
+export const threatSearchParamsMock: GetThreatListOptions = {
+ esClient,
+ query: '*:*',
+ language: 'kuery',
+ threatFilters: [],
+ index: ['threats-*'],
+ ruleExecutionLogger,
+ threatListConfig: {
+ _source: false,
+ fields: undefined,
+ },
+ pitId: 'mock',
+ reassignPitId: jest.fn(),
+ listClient: getListClientMock(),
+ searchAfter: undefined,
+ runtimeMappings: undefined,
+ exceptionFilter: undefined,
+};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.test.ts
new file mode 100644
index 0000000000000..38a6947beebcb
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.test.ts
@@ -0,0 +1,305 @@
+/*
+ * 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 { ThreatMatchQueryType } from './types';
+
+import { getSignalsQueryMapFromThreatIndex } from './get_signals_map_from_threat_index';
+import { getThreatList } from './get_threat_list';
+import { encodeThreatMatchNamedQuery } from './utils';
+import { MAX_NUMBER_OF_SIGNAL_MATCHES } from './enrich_signal_threat_matches';
+
+import { threatSearchParamsMock } from './get_signals_map_from_threat_index.mock';
+
+jest.mock('./get_threat_list', () => ({ getThreatList: jest.fn() }));
+
+const getThreatListMock = getThreatList as jest.Mock;
+
+export const namedQuery = encodeThreatMatchNamedQuery({
+ id: 'source-1',
+ index: 'source-*',
+ field: 'host.name',
+ value: 'localhost-1',
+ queryType: ThreatMatchQueryType.match,
+});
+
+const termsNamedQuery = encodeThreatMatchNamedQuery({
+ value: 'threat.indicator.domain',
+ field: 'event.domain',
+ queryType: ThreatMatchQueryType.term,
+});
+
+export const threatMock = {
+ _id: 'threat-id-1',
+ _index: 'threats-01',
+ matched_queries: [namedQuery],
+};
+
+const termsThreatMock = {
+ _id: 'threat-id-1',
+ _index: 'threats-01',
+ matched_queries: [termsNamedQuery],
+};
+
+getThreatListMock.mockReturnValue({ hits: { hits: [] } });
+
+describe('getSignalsQueryMapFromThreatIndex', () => {
+ it('should call getThreatList to fetch threats from ES', async () => {
+ getThreatListMock.mockReturnValue({ hits: { hits: [] } });
+
+ await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ });
+
+ expect(getThreatListMock).toHaveBeenCalledTimes(1);
+ expect(getThreatListMock).toHaveBeenCalledWith(threatSearchParamsMock);
+ });
+
+ it('should return empty signals map if getThreatList return empty results', async () => {
+ getThreatListMock.mockReturnValue({ hits: { hits: [] } });
+
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ });
+
+ expect(signalsQueryMap).toEqual(new Map());
+ });
+
+ it('should return signalsQueryMap for signals if threats search results exhausted', async () => {
+ const namedQuery2 = encodeThreatMatchNamedQuery({
+ id: 'source-2',
+ index: 'source-*',
+ field: 'host.name',
+ value: 'localhost-1',
+ queryType: ThreatMatchQueryType.match,
+ });
+
+ // the third request return empty results
+ getThreatListMock.mockReturnValueOnce({
+ hits: {
+ hits: [threatMock],
+ },
+ });
+ getThreatListMock.mockReturnValueOnce({
+ hits: {
+ hits: [
+ { ...threatMock, _id: 'threat-id-2', matched_queries: [namedQuery, namedQuery2] },
+ { ...threatMock, _id: 'threat-id-3' },
+ ],
+ },
+ });
+ getThreatListMock.mockReturnValueOnce({ hits: { hits: [] } });
+
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ });
+
+ expect(signalsQueryMap).toEqual(
+ new Map([
+ [
+ 'source-1',
+ [
+ {
+ id: 'threat-id-1',
+ index: 'threats-01',
+ field: 'host.name',
+ value: 'localhost-1',
+ queryType: ThreatMatchQueryType.match,
+ },
+ {
+ id: 'threat-id-2',
+ index: 'threats-01',
+ field: 'host.name',
+ value: 'localhost-1',
+ queryType: ThreatMatchQueryType.match,
+ },
+ {
+ id: 'threat-id-3',
+ index: 'threats-01',
+ field: 'host.name',
+ value: 'localhost-1',
+ queryType: ThreatMatchQueryType.match,
+ },
+ ],
+ ],
+ [
+ 'source-2',
+ [
+ {
+ id: 'threat-id-2',
+ index: 'threats-01',
+ field: 'host.name',
+ value: 'localhost-1',
+ queryType: ThreatMatchQueryType.match,
+ },
+ ],
+ ],
+ ])
+ );
+ });
+ it('should return signalsQueryMap for signals if threats number reaches max of MAX_NUMBER_OF_SIGNAL_MATCHES', async () => {
+ getThreatListMock.mockReturnValueOnce({
+ hits: {
+ hits: Array.from(Array(MAX_NUMBER_OF_SIGNAL_MATCHES + 1)).map(() => threatMock),
+ },
+ });
+
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ });
+
+ expect(signalsQueryMap.get('source-1')).toHaveLength(MAX_NUMBER_OF_SIGNAL_MATCHES);
+ });
+
+ it('should return empty signalsQueryMap for terms query if there no signalValueMap', async () => {
+ getThreatListMock.mockReturnValueOnce({
+ hits: {
+ hits: [termsThreatMock],
+ },
+ });
+
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ });
+
+ expect(signalsQueryMap).toEqual(new Map());
+ });
+
+ it('should return empty signalsQueryMap for terms query if there wrong value in threat indicator', async () => {
+ getThreatListMock.mockReturnValueOnce({
+ hits: {
+ hits: [
+ {
+ ...termsThreatMock,
+ _source: {
+ threat: {
+ indicator: {
+ domain: { a: 'b' },
+ },
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ const signalValueMap = {
+ 'event.domain': {
+ domain_1: ['signalId1', 'signalId2'],
+ },
+ };
+
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ signalValueMap,
+ });
+
+ expect(signalsQueryMap).toEqual(new Map());
+ });
+
+ it('should return signalsQueryMap from threat indicators for termsQuery', async () => {
+ getThreatListMock.mockReturnValueOnce({
+ hits: {
+ hits: [
+ {
+ ...termsThreatMock,
+ _source: {
+ threat: {
+ indicator: {
+ domain: 'domain_1',
+ },
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ const signalValueMap = {
+ 'event.domain': {
+ domain_1: ['signalId1', 'signalId2'],
+ },
+ };
+
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ signalValueMap,
+ });
+
+ const queries = [
+ {
+ field: 'event.domain',
+ value: 'threat.indicator.domain',
+ id: 'threat-id-1',
+ index: 'threats-01',
+ queryType: ThreatMatchQueryType.term,
+ },
+ ];
+
+ expect(signalsQueryMap).toEqual(
+ new Map([
+ ['signalId1', queries],
+ ['signalId2', queries],
+ ])
+ );
+ });
+
+ it('should return signalsQueryMap from threat indicators which has array values for termsQuery', async () => {
+ getThreatListMock.mockReturnValueOnce({
+ hits: {
+ hits: [
+ {
+ ...termsThreatMock,
+ _source: {
+ threat: {
+ indicator: {
+ domain: ['domain_3', 'domain_1', 'domain_2'],
+ },
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ const signalValueMap = {
+ 'event.domain': {
+ domain_1: ['signalId1'],
+ domain_2: ['signalId2'],
+ },
+ };
+
+ const signalsQueryMap = await getSignalsQueryMapFromThreatIndex({
+ threatSearchParams: threatSearchParamsMock,
+ eventsCount: 50,
+ signalValueMap,
+ });
+
+ const queries = [
+ {
+ field: 'event.domain',
+ value: 'threat.indicator.domain',
+ id: 'threat-id-1',
+ index: 'threats-01',
+ queryType: ThreatMatchQueryType.term,
+ },
+ ];
+
+ expect(signalsQueryMap).toEqual(
+ new Map([
+ ['signalId1', queries],
+ ['signalId2', queries],
+ ])
+ );
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.ts
new file mode 100644
index 0000000000000..0deb3beeee2e8
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_signals_map_from_threat_index.ts
@@ -0,0 +1,126 @@
+/*
+ * 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 { get } from 'lodash';
+
+import { ThreatMatchQueryType } from './types';
+import type {
+ GetThreatListOptions,
+ ThreatMatchNamedQuery,
+ ThreatTermNamedQuery,
+ ThreatListItem,
+ SignalValuesMap,
+} from './types';
+import { getThreatList } from './get_threat_list';
+import { decodeThreatMatchNamedQuery } from './utils';
+
+import { MAX_NUMBER_OF_SIGNAL_MATCHES } from './enrich_signal_threat_matches';
+
+export type SignalsQueryMap = Map;
+
+interface GetSignalsMatchesFromThreatIndexOptions {
+ threatSearchParams: Omit;
+ eventsCount: number;
+ signalValueMap?: SignalValuesMap;
+}
+
+/**
+ * fetches threats and creates signals map from results, that matches signal is with list of threat queries
+ */
+export const getSignalsQueryMapFromThreatIndex = async ({
+ threatSearchParams,
+ eventsCount,
+ signalValueMap,
+}: GetSignalsMatchesFromThreatIndexOptions): Promise => {
+ let threatList: Awaited> | undefined;
+ const signalsQueryMap = new Map();
+ // number of threat matches per signal is limited by MAX_NUMBER_OF_SIGNAL_MATCHES. Once it hits this number, threats stop to be processed for a signal
+ const maxThreatsReachedMap = new Map();
+
+ const addSignalValueToMap = ({
+ signalId,
+ threatHit,
+ decodedQuery,
+ }: {
+ signalId: string;
+ threatHit: ThreatListItem;
+ decodedQuery: ThreatMatchNamedQuery | ThreatTermNamedQuery;
+ }) => {
+ const signalMatch = signalsQueryMap.get(signalId);
+ if (!signalMatch) {
+ signalsQueryMap.set(signalId, []);
+ }
+
+ const threatQuery = {
+ id: threatHit._id,
+ index: threatHit._index,
+ field: decodedQuery.field,
+ value: decodedQuery.value,
+ queryType: decodedQuery.queryType,
+ };
+
+ if (!signalMatch) {
+ signalsQueryMap.set(signalId, [threatQuery]);
+ return;
+ }
+
+ if (signalMatch.length === MAX_NUMBER_OF_SIGNAL_MATCHES) {
+ maxThreatsReachedMap.set(signalId, true);
+ } else if (signalMatch.length < MAX_NUMBER_OF_SIGNAL_MATCHES) {
+ signalMatch.push(threatQuery);
+ }
+ };
+
+ while (
+ maxThreatsReachedMap.size < eventsCount &&
+ (threatList ? threatList?.hits.hits.length > 0 : true)
+ ) {
+ threatList = await getThreatList({
+ ...threatSearchParams,
+ searchAfter: threatList?.hits.hits[threatList.hits.hits.length - 1].sort || undefined,
+ });
+
+ threatList.hits.hits.forEach((threatHit) => {
+ const matchedQueries = threatHit?.matched_queries || [];
+
+ matchedQueries.forEach((matchedQuery) => {
+ const decodedQuery = decodeThreatMatchNamedQuery(matchedQuery);
+ const signalId = decodedQuery.id;
+
+ if (decodedQuery.queryType === ThreatMatchQueryType.term) {
+ const threatValue = get(threatHit?._source, decodedQuery.value);
+ const values = Array.isArray(threatValue) ? threatValue : [threatValue];
+
+ values.forEach((value) => {
+ if (value && signalValueMap) {
+ const ids = signalValueMap[decodedQuery.field][value?.toString()];
+
+ ids?.forEach((id: string) => {
+ addSignalValueToMap({
+ signalId: id,
+ threatHit,
+ decodedQuery,
+ });
+ });
+ }
+ });
+ } else {
+ if (!signalId) {
+ return;
+ }
+
+ addSignalValueToMap({
+ signalId,
+ threatHit,
+ decodedQuery,
+ });
+ }
+ });
+ });
+ }
+
+ return signalsQueryMap;
+};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts
index c2ee1fee3c75a..160de278f47e3 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts
@@ -11,7 +11,6 @@ import type {
GetThreatListOptions,
ThreatListCountOptions,
ThreatListDoc,
- ThreatListItem,
GetSortForThreatList,
} from './types';
@@ -20,8 +19,6 @@ import type {
*/
export const INDICATOR_PER_PAGE = 1000;
-const MAX_NUMBER_OF_THREATS = 10 * 1000;
-
export const getThreatList = async ({
esClient,
index,
@@ -116,24 +113,3 @@ export const getThreatListCount = async ({
});
return response.count;
};
-
-export const getAllThreatListHits = async (
- params: Omit
-): Promise => {
- let allThreatListHits: ThreatListItem[] = [];
- let threatList = await getThreatList({ ...params, searchAfter: undefined });
-
- allThreatListHits = allThreatListHits.concat(threatList.hits.hits);
-
- // to prevent loading in memory large number of results, that could lead to out of memory Kibana crash,
- // number of indicators is limited to MAX_NUMBER_OF_THREATS
- while (threatList.hits.hits.length !== 0 && allThreatListHits.length < MAX_NUMBER_OF_THREATS) {
- threatList = await getThreatList({
- ...params,
- searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort,
- });
-
- allThreatListHits = allThreatListHits.concat(threatList.hits.hits);
- }
- return allThreatListHits;
-};
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.test.ts
new file mode 100644
index 0000000000000..3c6bb7d8ee169
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.test.ts
@@ -0,0 +1,108 @@
+/*
+ * 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 { getThreatList } from './get_threat_list';
+import { getNamedQueryMock } from './enrich_signal_threat_matches.mock';
+import type { SignalSourceHit } from '../types';
+import { threatSearchParamsMock } from './get_signals_map_from_threat_index.mock';
+import { threatEnrichmentFactory } from './threat_enrichment_factory';
+import { enrichSignalThreatMatchesFromSignalsMap } from './enrich_signal_threat_matches';
+
+jest.mock('./get_threat_list', () => ({ getThreatList: jest.fn() }));
+jest.mock('./enrich_signal_threat_matches', () => ({
+ enrichSignalThreatMatchesFromSignalsMap: jest.fn(),
+}));
+
+const getThreatListMock = getThreatList as jest.Mock;
+const enrichSignalThreatMatchesFromSignalsMapMock =
+ enrichSignalThreatMatchesFromSignalsMap as jest.Mock;
+
+const signals = [
+ {
+ _id: 'source-id-1',
+ },
+ {
+ _id: 'source-id-2',
+ },
+];
+
+const signalsMapMock = new Map([
+ ['source-id-1', [getNamedQueryMock({ id: 'threat-1' }), getNamedQueryMock({ id: 'threat-2' })]],
+ // this signal's threats not present in signalsMap, so will be ignored in threatFilter
+ ['source-id-3', [getNamedQueryMock({ id: 'threat-x' }), getNamedQueryMock({ id: 'threat-y' })]],
+]);
+
+getThreatListMock.mockReturnValue({ hits: { hits: [] } });
+enrichSignalThreatMatchesFromSignalsMapMock.mockImplementation((_, getThreats) => getThreats());
+
+describe('threatEnrichmentFactory', () => {
+ it('enrichment should call enrichSignalThreatMatchesFromSignalsMap with correct params', async () => {
+ const enrichment = threatEnrichmentFactory({
+ signalsQueryMap: signalsMapMock,
+ threatIndicatorPath: 'indicator.mock',
+ threatFilters: ['mock-threat-filters'],
+ threatSearchParams: threatSearchParamsMock,
+ });
+
+ await enrichment(signals as SignalSourceHit[]);
+
+ expect(enrichSignalThreatMatchesFromSignalsMap).toHaveBeenCalledWith(
+ signals,
+ expect.any(Function),
+ 'indicator.mock',
+ signalsMapMock
+ );
+ });
+
+ it('enrichment should call getThreatList with matched threat ids filters in signalsMap', async () => {
+ const enrichment = threatEnrichmentFactory({
+ signalsQueryMap: signalsMapMock,
+ threatIndicatorPath: 'indicator.mock',
+ threatFilters: ['mock-threat-filters'],
+ threatSearchParams: threatSearchParamsMock,
+ });
+
+ await enrichment(signals as SignalSourceHit[]);
+
+ expect(getThreatListMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ threatFilters: [
+ 'mock-threat-filters',
+ {
+ query: {
+ bool: {
+ filter: {
+ ids: { values: ['threat-1', 'threat-2'] },
+ },
+ },
+ },
+ },
+ ],
+ })
+ );
+ });
+
+ it('enrichment should call getThreatList with correct threatListConfig', async () => {
+ const enrichment = threatEnrichmentFactory({
+ signalsQueryMap: new Map(),
+ threatIndicatorPath: 'indicator.mock',
+ threatFilters: ['mock-threat-filters'],
+ threatSearchParams: threatSearchParamsMock,
+ });
+
+ await enrichment(signals as SignalSourceHit[]);
+
+ expect(getThreatListMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ threatListConfig: {
+ _source: ['indicator.mock.*', 'threat.feed.*'],
+ fields: undefined,
+ },
+ })
+ );
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.ts
new file mode 100644
index 0000000000000..9d8da19f613bb
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/threat_enrichment_factory.ts
@@ -0,0 +1,76 @@
+/*
+ * 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 { CreateEventSignalOptions, GetThreatListOptions } from './types';
+import type { SignalSourceHit } from '../types';
+import { getThreatList } from './get_threat_list';
+import { enrichSignalThreatMatchesFromSignalsMap } from './enrich_signal_threat_matches';
+import { type SignalsQueryMap } from './get_signals_map_from_threat_index';
+
+interface ThreatEnrichmentFactoryOptions {
+ threatIndicatorPath: CreateEventSignalOptions['threatIndicatorPath'];
+ signalsQueryMap: SignalsQueryMap;
+ threatFilters: CreateEventSignalOptions['threatFilters'];
+ threatSearchParams: Omit;
+}
+
+/**
+ * returns threatEnrichment method used events-first search
+ */
+export const threatEnrichmentFactory = ({
+ signalsQueryMap,
+ threatIndicatorPath,
+ threatFilters,
+ threatSearchParams,
+}: ThreatEnrichmentFactoryOptions) => {
+ const threatEnrichment = (signals: SignalSourceHit[]): Promise => {
+ const getThreats = async () => {
+ const threatIds = signals
+ .map((s) => s._id)
+ .reduce((acc, id) => {
+ return [
+ ...new Set([
+ ...acc,
+ ...(signalsQueryMap.get(id) ?? []).map((threatQueryMatched) => threatQueryMatched.id),
+ ]),
+ ];
+ }, [])
+ .flat();
+
+ const matchedThreatsFilter = {
+ query: {
+ bool: {
+ filter: {
+ ids: { values: threatIds },
+ },
+ },
+ },
+ };
+
+ const threatResponse = await getThreatList({
+ ...threatSearchParams,
+ threatListConfig: {
+ _source: [`${threatIndicatorPath}.*`, 'threat.feed.*'],
+ fields: undefined,
+ },
+ threatFilters: [...threatFilters, matchedThreatsFilter],
+ searchAfter: undefined,
+ });
+
+ return threatResponse.hits.hits;
+ };
+
+ return enrichSignalThreatMatchesFromSignalsMap(
+ signals,
+ getThreats,
+ threatIndicatorPath,
+ signalsQueryMap
+ );
+ };
+
+ return threatEnrichment;
+};
From 79a8c2db63ebb87a20b4442f6023b7a500cad16d Mon Sep 17 00:00:00 2001
From: Andrew Kroh
Date: Mon, 6 Feb 2023 16:46:16 -0500
Subject: [PATCH 022/134] [Fleet] openapi spec - remove duplicated path
parameters (#150260)
Several path parameter definitions were duplicated between the path's
`parameters` and the method's `parameters`. These two lists are joined
so there is no need to re-declare a parameter.
I observed these errors while using github.com/deepmap/oapi-codegen with
the Fleet openapi definition.
```
path '/package_policies/{packagePolicyId}' has 1 positional parameters, but spec has 2 declared
path '/epm/packages/{pkgName}/{pkgVersion}' has 2 positional parameters, but spec has 4 declared
path '/fleet_server_hosts/{itemId}' has 1 positional parameters, but spec has 2 declared
path '/proxies/{itemId}' has 1 positional parameters, but spec has 2 declared
```
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../plugins/fleet/common/openapi/bundled.json | 58 -------------------
.../plugins/fleet/common/openapi/bundled.yaml | 36 ------------
...epm@packages@{pkg_name}@{pkg_version}.yaml | 11 ----
.../paths/fleet_server_hosts@{item_id}.yaml | 10 ----
.../package_policies@{package_policy_id}.yaml | 5 --
.../openapi/paths/proxies@{item_id}.yaml | 10 ----
6 files changed, 130 deletions(-)
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json
index 0a62474c89056..9b994df3afe33 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.json
+++ b/x-pack/plugins/fleet/common/openapi/bundled.json
@@ -880,24 +880,6 @@
},
"operationId": "update-package",
"description": "",
- "parameters": [
- {
- "schema": {
- "type": "string"
- },
- "name": "pkgName",
- "in": "path",
- "required": true
- },
- {
- "schema": {
- "type": "string"
- },
- "name": "pkgVersion",
- "in": "path",
- "required": true
- }
- ],
"requestBody": {
"content": {
"application/json": {
@@ -3625,14 +3607,6 @@
}
},
"parameters": [
- {
- "schema": {
- "type": "string"
- },
- "name": "packagePolicyId",
- "in": "path",
- "required": true
- },
{
"schema": {
"type": "boolean"
@@ -4273,14 +4247,6 @@
}
},
"parameters": [
- {
- "schema": {
- "type": "string"
- },
- "name": "itemId",
- "in": "path",
- "required": true
- },
{
"$ref": "#/components/parameters/kbn_xsrf"
}
@@ -4333,14 +4299,6 @@
}
},
"parameters": [
- {
- "schema": {
- "type": "string"
- },
- "name": "itemId",
- "in": "path",
- "required": true
- },
{
"$ref": "#/components/parameters/kbn_xsrf"
}
@@ -4503,14 +4461,6 @@
}
},
"parameters": [
- {
- "schema": {
- "type": "string"
- },
- "name": "itemId",
- "in": "path",
- "required": true
- },
{
"$ref": "#/components/parameters/kbn_xsrf"
}
@@ -4569,14 +4519,6 @@
}
},
"parameters": [
- {
- "schema": {
- "type": "string"
- },
- "name": "itemId",
- "in": "path",
- "required": true
- },
{
"$ref": "#/components/parameters/kbn_xsrf"
}
diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml
index 7b93038b146bc..65bab73457170 100644
--- a/x-pack/plugins/fleet/common/openapi/bundled.yaml
+++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml
@@ -556,17 +556,6 @@ paths:
- items
operationId: update-package
description: ''
- parameters:
- - schema:
- type: string
- name: pkgName
- in: path
- required: true
- - schema:
- type: string
- name: pkgVersion
- in: path
- required: true
requestBody:
content:
application/json:
@@ -2247,11 +2236,6 @@ paths:
required:
- id
parameters:
- - schema:
- type: string
- name: packagePolicyId
- in: path
- required: true
- schema:
type: boolean
name: force
@@ -2652,11 +2636,6 @@ paths:
required:
- id
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: '#/components/parameters/kbn_xsrf'
put:
summary: Fleet Server Hosts - Update
@@ -2688,11 +2667,6 @@ paths:
required:
- item
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: '#/components/parameters/kbn_xsrf'
/proxies:
get:
@@ -2795,11 +2769,6 @@ paths:
required:
- id
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: '#/components/parameters/kbn_xsrf'
put:
summary: Fleet Proxies - Update
@@ -2835,11 +2804,6 @@ paths:
required:
- item
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: '#/components/parameters/kbn_xsrf'
/kubernetes:
get:
diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml
index c6a5e55123591..8fe228f91bbd0 100644
--- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml
+++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml
@@ -143,17 +143,6 @@ put:
- items
operationId: update-package
description: ''
- parameters:
- - schema:
- type: string
- name: pkgName
- in: path
- required: true
- - schema:
- type: string
- name: pkgVersion
- in: path
- required: true
requestBody:
content:
application/json:
diff --git a/x-pack/plugins/fleet/common/openapi/paths/fleet_server_hosts@{item_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/fleet_server_hosts@{item_id}.yaml
index b2a50f8b52b8e..141274274b840 100644
--- a/x-pack/plugins/fleet/common/openapi/paths/fleet_server_hosts@{item_id}.yaml
+++ b/x-pack/plugins/fleet/common/openapi/paths/fleet_server_hosts@{item_id}.yaml
@@ -36,11 +36,6 @@ delete:
required:
- id
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: ../components/headers/kbn_xsrf.yaml
put:
summary: Fleet Server Hosts - Update
@@ -72,9 +67,4 @@ put:
required:
- item
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: ../components/headers/kbn_xsrf.yaml
diff --git a/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml
index bb4a76f1cd3df..72773f43483be 100644
--- a/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml
+++ b/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml
@@ -62,11 +62,6 @@ delete:
required:
- id
parameters:
- - schema:
- type: string
- name: packagePolicyId
- in: path
- required: true
- schema:
type: boolean
name: force
diff --git a/x-pack/plugins/fleet/common/openapi/paths/proxies@{item_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/proxies@{item_id}.yaml
index 3c269de04b882..882ddd5bf3f07 100644
--- a/x-pack/plugins/fleet/common/openapi/paths/proxies@{item_id}.yaml
+++ b/x-pack/plugins/fleet/common/openapi/paths/proxies@{item_id}.yaml
@@ -36,11 +36,6 @@ delete:
required:
- id
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: ../components/headers/kbn_xsrf.yaml
put:
summary: Fleet Proxies - Update
@@ -76,9 +71,4 @@ put:
required:
- item
parameters:
- - schema:
- type: string
- name: itemId
- in: path
- required: true
- $ref: ../components/headers/kbn_xsrf.yaml
From 62d5a2a702268a7f807072740a9db30c620c92a2 Mon Sep 17 00:00:00 2001
From: Maxim Palenov
Date: Mon, 6 Feb 2023 22:59:03 +0100
Subject: [PATCH 023/134] [Security Solution] Add clearing rules table state
functionality (#150059)
**Addresses:** https://github.com/elastic/kibana/issues/145968
## Summary
This PR adds functionality to clear persisted rules table state implemented in https://github.com/elastic/kibana/issues/140263.
https://user-images.githubusercontent.com/3775283/216311325-e19e81e1-f232-4c16-b9df-a49fcc8c98d0.mov
### Checklist
- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials
- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
---
.../components/rules_table/index.tsx | 2 -
.../__mocks__/rules_table_context.tsx | 2 +
.../rules_table/rules_table_context.test.tsx | 115 +++
.../rules_table/rules_table_context.tsx | 82 +-
.../rules_table/rules_table_defaults.ts | 1 +
...initialize_rules_table_saved_state.test.ts | 626 --------------
.../use_rules_table_saved_state.test.ts | 762 ++++++++++++++++++
...tate.ts => use_rules_table_saved_state.ts} | 99 ++-
.../rules_table/rules_table_utility_bar.tsx | 10 +
.../detection_engine/rules/translations.ts | 7 +
10 files changed, 1019 insertions(+), 687 deletions(-)
create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx
delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_initialize_rules_table_saved_state.test.ts
create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_rules_table_saved_state.test.ts
rename x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/{use_initialize_rules_table_saved_state.ts => use_rules_table_saved_state.ts} (56%)
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/index.tsx
index 10d3a86b3ca5a..58ba3c2f96a5c 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/index.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/index.tsx
@@ -9,7 +9,6 @@ import { EuiSpacer } from '@elastic/eui';
import React from 'react';
import { useRouteSpy } from '../../../../common/utils/route/use_route_spy';
import { RulesManagementTour } from './rules_table/guided_onboarding/rules_management_tour';
-import { useInitializeRulesTableSavedState } from './rules_table/use_initialize_rules_table_saved_state';
import { useSyncRulesTableSavedState } from './rules_table/use_sync_rules_table_saved_state';
import { RulesTables } from './rules_tables';
import type { AllRulesTabs } from './rules_table_toolbar';
@@ -24,7 +23,6 @@ import { RulesTableToolbar } from './rules_table_toolbar';
* * Import/Export
*/
export const AllRules = React.memo(() => {
- useInitializeRulesTableSavedState();
useSyncRulesTableSavedState();
const [{ tabName }] = useRouteSpy();
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx
index 3344b013398f3..1a03370ea5915 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx
@@ -40,6 +40,7 @@ export const useRulesTableContextMock = {
loadingRuleIds: [],
loadingRulesAction: null,
selectedRuleIds: [],
+ isDefault: true,
},
actions: {
reFetchRules: jest.fn(),
@@ -54,6 +55,7 @@ export const useRulesTableContextMock = {
setSelectedRuleIds: jest.fn(),
setSortingOptions: jest.fn(),
clearRulesSelection: jest.fn(),
+ clearFilters: jest.fn(),
},
}),
};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx
new file mode 100644
index 0000000000000..552ca7b0cbd88
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx
@@ -0,0 +1,115 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { renderHook } from '@testing-library/react-hooks';
+import type { PropsWithChildren } from 'react';
+import React from 'react';
+import { useUiSetting$ } from '../../../../../common/lib/kibana';
+import type { RulesTableState } from './rules_table_context';
+import { RulesTableContextProvider, useRulesTableContext } from './rules_table_context';
+import {
+ DEFAULT_FILTER_OPTIONS,
+ DEFAULT_PAGE,
+ DEFAULT_RULES_PER_PAGE,
+ DEFAULT_SORTING_OPTIONS,
+} from './rules_table_defaults';
+import { RuleSource } from './rules_table_saved_state';
+import { useFindRulesInMemory } from './use_find_rules_in_memory';
+import { useRulesTableSavedState } from './use_rules_table_saved_state';
+
+jest.mock('../../../../../common/lib/kibana');
+jest.mock('./use_find_rules_in_memory');
+jest.mock('./use_rules_table_saved_state');
+
+function renderUseRulesTableContext(
+ savedState: ReturnType
+): RulesTableState {
+ (useFindRulesInMemory as jest.Mock).mockReturnValue({
+ data: { rules: [], total: 0 },
+ refetch: jest.fn(),
+ dataUpdatedAt: 0,
+ isFetched: false,
+ isFetching: false,
+ isLoading: false,
+ isRefetching: false,
+ });
+ (useUiSetting$ as jest.Mock).mockReturnValue([{ on: false, value: 0, idleTimeout: 0 }]);
+ (useRulesTableSavedState as jest.Mock).mockReturnValue(savedState);
+
+ const wrapper = ({ children }: PropsWithChildren<{}>) => (
+ {children}
+ );
+ const {
+ result: {
+ current: { state },
+ },
+ } = renderHook(() => useRulesTableContext(), { wrapper });
+
+ return state;
+}
+
+describe('RulesTableContextProvider', () => {
+ describe('persisted state', () => {
+ it('restores persisted rules table state', () => {
+ const state = renderUseRulesTableContext({
+ filter: {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ },
+ sorting: {
+ field: 'name',
+ order: 'asc',
+ },
+ pagination: {
+ page: 2,
+ perPage: 10,
+ },
+ });
+
+ expect(state.filterOptions).toEqual({
+ filter: 'test',
+ tags: ['test'],
+ showCustomRules: true,
+ showElasticRules: false,
+ enabled: true,
+ });
+ expect(state.sortingOptions).toEqual({
+ field: 'name',
+ order: 'asc',
+ });
+ expect(state.pagination).toEqual({
+ page: 2,
+ perPage: 10,
+ total: 0,
+ });
+ expect(state.isDefault).toBeFalsy();
+ });
+
+ it('restores default rules table state', () => {
+ const state = renderUseRulesTableContext({});
+
+ expect(state.filterOptions).toEqual({
+ filter: DEFAULT_FILTER_OPTIONS.filter,
+ tags: DEFAULT_FILTER_OPTIONS.tags,
+ showCustomRules: DEFAULT_FILTER_OPTIONS.showCustomRules,
+ showElasticRules: DEFAULT_FILTER_OPTIONS.showElasticRules,
+ });
+ expect(state.sortingOptions).toEqual({
+ field: DEFAULT_SORTING_OPTIONS.field,
+ order: DEFAULT_SORTING_OPTIONS.order,
+ });
+ expect(state.pagination).toEqual({
+ page: DEFAULT_PAGE,
+ perPage: DEFAULT_RULES_PER_PAGE,
+ total: 0,
+ });
+ expect(state.isDefault).toBeTruthy();
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx
index a66361271427a..5667b544b7b72 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx
@@ -14,9 +14,12 @@ import React, {
useState,
useRef,
} from 'react';
+import { isEqual } from 'lodash';
import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants';
import { invariant } from '../../../../../../common/utils/invariant';
+import { useReplaceUrlParams } from '../../../../../common/utils/global_query_string/helpers';
import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana';
+import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state';
import type {
FilterOptions,
PaginationOptions,
@@ -29,8 +32,11 @@ import {
DEFAULT_FILTER_OPTIONS,
DEFAULT_SORTING_OPTIONS,
} from './rules_table_defaults';
+import { RuleSource } from './rules_table_saved_state';
import { useFindRulesInMemory } from './use_find_rules_in_memory';
+import { useRulesTableSavedState } from './use_rules_table_saved_state';
import { getRulesComparator } from './utils';
+import { RULES_TABLE_STATE_STORAGE_KEY } from '../constants';
export interface RulesTableState {
/**
@@ -101,6 +107,10 @@ export interface RulesTableState {
* Currently selected table sorting
*/
sortingOptions: SortingOptions;
+ /**
+ * Whether the state has its default value
+ */
+ isDefault: boolean;
}
export type LoadingRuleAction =
@@ -142,6 +152,10 @@ export interface RulesTableActions {
* clears rules selection on a page
*/
clearRulesSelection: () => void;
+ /**
+ * Clears rules table filters
+ */
+ clearFilters: () => void;
}
export interface RulesTableContextType {
@@ -163,13 +177,29 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
value: number;
idleTimeout: number;
}>(DEFAULT_RULES_TABLE_REFRESH_SETTING);
- const { storage } = useKibana().services;
+ const { storage, sessionStorage } = useKibana().services;
+ const {
+ filter: savedFilter,
+ sorting: savedSorting,
+ pagination: savedPagination,
+ } = useRulesTableSavedState();
const [isInMemorySorting, setIsInMemorySorting] = useState(
storage.get(IN_MEMORY_STORAGE_KEY) ?? false
);
- const [filterOptions, setFilterOptions] = useState(DEFAULT_FILTER_OPTIONS);
- const [sortingOptions, setSortingOptions] = useState(DEFAULT_SORTING_OPTIONS);
+ const [filterOptions, setFilterOptions] = useState({
+ filter: savedFilter?.searchTerm ?? DEFAULT_FILTER_OPTIONS.filter,
+ tags: savedFilter?.tags ?? DEFAULT_FILTER_OPTIONS.tags,
+ showCustomRules:
+ savedFilter?.source === RuleSource.Custom ?? DEFAULT_FILTER_OPTIONS.showCustomRules,
+ showElasticRules:
+ savedFilter?.source === RuleSource.Prebuilt ?? DEFAULT_FILTER_OPTIONS.showElasticRules,
+ enabled: savedFilter?.enabled,
+ });
+ const [sortingOptions, setSortingOptions] = useState({
+ field: savedSorting?.field ?? DEFAULT_SORTING_OPTIONS.field,
+ order: savedSorting?.order ?? DEFAULT_SORTING_OPTIONS.order,
+ });
const [isAllSelected, setIsAllSelected] = useState(false);
const [isRefreshOn, setIsRefreshOn] = useState(autoRefreshSettings.on);
const [loadingRules, setLoadingRules] = useState({
@@ -177,8 +207,8 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
action: null,
});
const [isPreflightInProgress, setIsPreflightInProgress] = useState(false);
- const [page, setPage] = useState(DEFAULT_PAGE);
- const [perPage, setPerPage] = useState(DEFAULT_RULES_PER_PAGE);
+ const [page, setPage] = useState(savedPagination?.page ?? DEFAULT_PAGE);
+ const [perPage, setPerPage] = useState(savedPagination?.perPage ?? DEFAULT_RULES_PER_PAGE);
const [selectedRuleIds, setSelectedRuleIds] = useState([]);
const autoRefreshBeforePause = useRef(null);
@@ -211,6 +241,26 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
setIsAllSelected(false);
}, []);
+ const replaceUrlParams = useReplaceUrlParams();
+ const clearFilters = useCallback(() => {
+ setFilterOptions({
+ filter: DEFAULT_FILTER_OPTIONS.filter,
+ showElasticRules: DEFAULT_FILTER_OPTIONS.showElasticRules,
+ showCustomRules: DEFAULT_FILTER_OPTIONS.showCustomRules,
+ tags: DEFAULT_FILTER_OPTIONS.tags,
+ enabled: undefined,
+ });
+ setSortingOptions({
+ field: DEFAULT_SORTING_OPTIONS.field,
+ order: DEFAULT_SORTING_OPTIONS.order,
+ });
+ setPage(DEFAULT_PAGE);
+ setPerPage(DEFAULT_RULES_PER_PAGE);
+
+ replaceUrlParams({ [URL_PARAM_KEY.rulesTable]: null });
+ sessionStorage.remove(RULES_TABLE_STATE_STORAGE_KEY);
+ }, [setFilterOptions, setSortingOptions, setPage, setPerPage, replaceUrlParams, sessionStorage]);
+
useEffect(() => {
// pause table auto refresh when any of rule selected
// store current auto refresh value, to use it later, when all rules selection will be cleared
@@ -262,6 +312,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
setSortingOptions,
clearRulesSelection,
setIsPreflightInProgress,
+ clearFilters,
}),
[
refetch,
@@ -276,6 +327,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
setSortingOptions,
clearRulesSelection,
setIsPreflightInProgress,
+ clearFilters,
]
);
@@ -286,7 +338,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
pagination: {
page,
perPage,
- total: isInMemorySorting ? rules.length : total,
+ total,
},
filterOptions,
isPreflightInProgress,
@@ -303,6 +355,11 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide
loadingRulesAction: loadingRules.action,
selectedRuleIds,
sortingOptions,
+ isDefault: isDefaultState(filterOptions, sortingOptions, {
+ page,
+ perPage,
+ total: isInMemorySorting ? rules.length : total,
+ }),
},
actions,
}),
@@ -346,3 +403,16 @@ export const useRulesTableContext = (): RulesTableContextType => {
export const useRulesTableContextOptional = (): RulesTableContextType | null =>
useContext(RulesTableContext);
+
+function isDefaultState(
+ filter: FilterOptions,
+ sorting: SortingOptions,
+ pagination: PaginationOptions
+): boolean {
+ return (
+ isEqual(filter, DEFAULT_FILTER_OPTIONS) &&
+ isEqual(sorting, DEFAULT_SORTING_OPTIONS) &&
+ pagination.page === DEFAULT_PAGE &&
+ pagination.perPage === DEFAULT_RULES_PER_PAGE
+ );
+}
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts
index 4cee292159691..6694b0a3a4ecd 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts
@@ -12,6 +12,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
tags: [],
showCustomRules: false,
showElasticRules: false,
+ enabled: undefined,
};
export const DEFAULT_SORTING_OPTIONS: SortingOptions = {
field: 'enabled',
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_initialize_rules_table_saved_state.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_initialize_rules_table_saved_state.test.ts
deleted file mode 100644
index b7a594034f034..0000000000000
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_initialize_rules_table_saved_state.test.ts
+++ /dev/null
@@ -1,626 +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 { renderHook } from '@testing-library/react-hooks';
-import { RULES_TABLE_MAX_PAGE_SIZE } from '../../../../../../common/constants';
-import { useRulesTableContextMock } from './__mocks__/rules_table_context';
-import { useInitializeRulesTableSavedState } from './use_initialize_rules_table_saved_state';
-import type {
- RulesTableStorageSavedState,
- RulesTableUrlSavedState,
-} from './rules_table_saved_state';
-import { RuleSource } from './rules_table_saved_state';
-import { DEFAULT_FILTER_OPTIONS, DEFAULT_SORTING_OPTIONS } from './rules_table_defaults';
-import { useRulesTableContext } from './rules_table_context';
-import { mockRulesTablePersistedState } from './__mocks__/mock_rules_table_persistent_state';
-
-jest.mock('../../../../../common/lib/kibana');
-jest.mock('../../../../../common/utils/global_query_string/helpers');
-jest.mock('./rules_table_context');
-
-describe('useInitializeRulesTableSavedState', () => {
- const urlSavedState: RulesTableUrlSavedState = {
- searchTerm: 'test',
- source: RuleSource.Custom,
- tags: ['test'],
- field: 'name',
- order: 'asc',
- page: 2,
- perPage: 10,
- enabled: true,
- };
- const storageSavedState: RulesTableStorageSavedState = {
- searchTerm: 'test',
- source: RuleSource.Custom,
- tags: ['test'],
- field: 'name',
- order: 'asc',
- perPage: 20,
- enabled: false,
- };
- const rulesTableContext = useRulesTableContextMock.create();
- const actions = rulesTableContext.actions;
-
- beforeEach(() => {
- jest.clearAllMocks();
- (useRulesTableContext as jest.Mock).mockReturnValue(rulesTableContext);
- });
-
- describe('when state is not saved', () => {
- beforeEach(() => {
- mockRulesTablePersistedState({ urlState: null, storageState: null });
- });
-
- it('does not restore the state', () => {
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).not.toHaveBeenCalled();
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
- });
-
- describe('when state is saved in the url', () => {
- beforeEach(() => {
- mockRulesTablePersistedState({ urlState: urlSavedState, storageState: null });
- });
-
- it('restores the state', () => {
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: urlSavedState.searchTerm,
- showCustomRules: urlSavedState.source === RuleSource.Custom,
- showElasticRules: urlSavedState.source === RuleSource.Prebuilt,
- tags: urlSavedState.tags,
- enabled: urlSavedState.enabled,
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- field: urlSavedState.field,
- order: urlSavedState.order,
- });
- expect(actions.setPage).toHaveBeenCalledWith(urlSavedState.page);
- expect(actions.setPerPage).toHaveBeenCalledWith(urlSavedState.perPage);
- });
-
- it('restores the state ignoring negative page size', () => {
- mockRulesTablePersistedState({
- urlState: { ...urlSavedState, perPage: -1 },
- storageState: null,
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores the state ignoring the page size larger than max allowed', () => {
- mockRulesTablePersistedState({
- urlState: { ...urlSavedState, perPage: RULES_TABLE_MAX_PAGE_SIZE + 1 },
- storageState: null,
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
- });
-
- describe('when partial state is saved in the url', () => {
- it('restores only the search term', () => {
- mockRulesTablePersistedState({ urlState: { searchTerm: 'test' }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- filter: 'test',
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only show prebuilt rules filter', () => {
- mockRulesTablePersistedState({
- urlState: { source: RuleSource.Prebuilt },
- storageState: null,
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- showElasticRules: true,
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only show custom rules filter', () => {
- mockRulesTablePersistedState({ urlState: { source: RuleSource.Custom }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- showCustomRules: true,
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only tags', () => {
- mockRulesTablePersistedState({ urlState: { tags: ['test'] }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- tags: ['test'],
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only enabled state filter', () => {
- mockRulesTablePersistedState({ urlState: { enabled: true }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- enabled: true,
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only sorting field', () => {
- mockRulesTablePersistedState({ urlState: { field: 'name' }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- field: 'name',
- order: DEFAULT_SORTING_OPTIONS.order,
- });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only sorting order', () => {
- mockRulesTablePersistedState({ urlState: { order: 'asc' }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- field: DEFAULT_SORTING_OPTIONS.field,
- order: 'asc',
- });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only page number', () => {
- mockRulesTablePersistedState({ urlState: { page: 10 }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).toHaveBeenCalledWith(10);
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only page size', () => {
- mockRulesTablePersistedState({ urlState: { perPage: 10 }, storageState: null });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).toHaveBeenCalledWith(10);
- });
- });
-
- describe('when state is saved in the storage', () => {
- beforeEach(() => {
- mockRulesTablePersistedState({ urlState: null, storageState: storageSavedState });
- });
-
- it('restores the state', () => {
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: storageSavedState.searchTerm,
- showCustomRules: storageSavedState.source === RuleSource.Custom,
- showElasticRules: storageSavedState.source === RuleSource.Prebuilt,
- tags: storageSavedState.tags,
- enabled: storageSavedState.enabled,
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- field: storageSavedState.field,
- order: storageSavedState.order,
- });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).toHaveBeenCalledWith(storageSavedState.perPage);
- });
-
- it('restores the state ignoring negative page size', () => {
- mockRulesTablePersistedState({
- urlState: null,
- storageState: { ...storageSavedState, perPage: -1 },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores the state ignoring the page size larger than max allowed', () => {
- mockRulesTablePersistedState({
- urlState: null,
- storageState: { ...storageSavedState, perPage: RULES_TABLE_MAX_PAGE_SIZE + 1 },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
- });
-
- describe('when partial state is saved in the storage', () => {
- it('restores only the search term', () => {
- mockRulesTablePersistedState({ urlState: null, storageState: { searchTerm: 'test' } });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- filter: 'test',
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only show prebuilt rules filter', () => {
- mockRulesTablePersistedState({
- urlState: null,
- storageState: { source: RuleSource.Prebuilt },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- showElasticRules: true,
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only show custom rules filter', () => {
- mockRulesTablePersistedState({ urlState: null, storageState: { source: RuleSource.Custom } });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- showCustomRules: true,
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only tags', () => {
- mockRulesTablePersistedState({ urlState: null, storageState: { tags: ['test'] } });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- tags: ['test'],
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only enabled state filter', () => {
- mockRulesTablePersistedState({ urlState: null, storageState: { enabled: true } });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- enabled: true,
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only sorting field', () => {
- mockRulesTablePersistedState({ urlState: null, storageState: { field: 'name' } });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- field: 'name',
- order: DEFAULT_SORTING_OPTIONS.order,
- });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only sorting order', () => {
- mockRulesTablePersistedState({ urlState: null, storageState: { order: 'asc' } });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- field: DEFAULT_SORTING_OPTIONS.field,
- order: 'asc',
- });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('does not restore the page number', () => {
- mockRulesTablePersistedState({
- urlState: null,
- // @ts-expect-error Passing an invalid value for the test
- storageState: { page: 10 },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
-
- it('restores only page size', () => {
- mockRulesTablePersistedState({ urlState: null, storageState: { perPage: 10 } });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith(DEFAULT_FILTER_OPTIONS);
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).toHaveBeenCalledWith(10);
- });
- });
-
- describe('when state is saved in the url and the storage', () => {
- beforeEach(() => {
- mockRulesTablePersistedState({ urlState: urlSavedState, storageState: storageSavedState });
- });
-
- it('restores the state from the url', () => {
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: urlSavedState.searchTerm,
- showCustomRules: urlSavedState.source === RuleSource.Custom,
- showElasticRules: urlSavedState.source === RuleSource.Prebuilt,
- tags: urlSavedState.tags,
- enabled: urlSavedState.enabled,
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- field: urlSavedState.field,
- order: urlSavedState.order,
- });
- expect(actions.setPage).toHaveBeenCalledWith(urlSavedState.page);
- expect(actions.setPerPage).toHaveBeenCalledWith(urlSavedState.perPage);
- });
- });
-
- describe('when partial state is saved in the url and in the storage', () => {
- it('restores only the search term', () => {
- mockRulesTablePersistedState({
- urlState: { searchTerm: 'test' },
- storageState: { field: 'name' },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- ...DEFAULT_FILTER_OPTIONS,
- filter: 'test',
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({
- ...DEFAULT_SORTING_OPTIONS,
- field: 'name',
- });
- });
- });
-
- describe('when there is invalid state in the url', () => {
- it('does not restore the filter', () => {
- mockRulesTablePersistedState({
- urlState: {
- searchTerm: 'test',
- source: RuleSource.Custom,
- // @ts-expect-error Passing an invalid value for the test
- tags: [1, 2, 3],
- field: 'name',
- order: 'asc',
- page: 2,
- perPage: 10,
- },
- storageState: null,
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: '',
- showCustomRules: false,
- showElasticRules: false,
- tags: [],
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({ field: 'name', order: 'asc' });
- expect(actions.setPage).toHaveBeenCalledWith(2);
- expect(actions.setPerPage).toHaveBeenCalledWith(10);
- });
-
- it('does not restore the sorting', () => {
- mockRulesTablePersistedState({
- urlState: {
- searchTerm: 'test',
- source: RuleSource.Custom,
- tags: ['test'],
- field: 'name',
- // @ts-expect-error Passing an invalid value for the test
- order: 'abc',
- page: 2,
- perPage: 10,
- },
- storageState: null,
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: 'test',
- showCustomRules: true,
- showElasticRules: false,
- tags: ['test'],
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).toHaveBeenCalledWith(2);
- expect(actions.setPerPage).toHaveBeenCalledWith(10);
- });
-
- it('does not restore the pagination', () => {
- mockRulesTablePersistedState({
- urlState: {
- searchTerm: 'test',
- source: RuleSource.Custom,
- tags: ['test'],
- field: 'name',
- order: 'asc',
- // @ts-expect-error Passing an invalid value for the test
- page: 'aaa',
- perPage: 10,
- },
- storageState: null,
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: 'test',
- showCustomRules: true,
- showElasticRules: false,
- tags: ['test'],
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({ field: 'name', order: 'asc' });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
- });
-
- describe('when there is invalid state in the storage', () => {
- it('does not restore the filter', () => {
- mockRulesTablePersistedState({
- urlState: null,
- storageState: {
- searchTerm: 'test',
- source: RuleSource.Custom,
- // @ts-expect-error Passing an invalid value for the test
- tags: [1, 2, 3],
- field: 'name',
- order: 'asc',
- perPage: 10,
- },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: '',
- showCustomRules: false,
- showElasticRules: false,
- tags: [],
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({ field: 'name', order: 'asc' });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).toHaveBeenCalledWith(10);
- });
-
- it('does not restore the sorting', () => {
- mockRulesTablePersistedState({
- urlState: null,
- storageState: {
- searchTerm: 'test',
- source: RuleSource.Custom,
- tags: ['test'],
- field: 'name',
- // @ts-expect-error Passing an invalid value for the test
- order: 'abc',
- perPage: 10,
- },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: 'test',
- showCustomRules: true,
- showElasticRules: false,
- tags: ['test'],
- });
- expect(actions.setSortingOptions).not.toHaveBeenCalled();
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).toHaveBeenCalledWith(10);
- });
-
- it('does not restore the pagination', () => {
- mockRulesTablePersistedState({
- urlState: null,
- storageState: {
- searchTerm: 'test',
- source: RuleSource.Custom,
- tags: ['test'],
- field: 'name',
- order: 'asc',
- // @ts-expect-error Passing an invalid value for the test
- perPage: 'aaa',
- },
- });
-
- renderHook(() => useInitializeRulesTableSavedState());
-
- expect(actions.setFilterOptions).toHaveBeenCalledWith({
- filter: 'test',
- showCustomRules: true,
- showElasticRules: false,
- tags: ['test'],
- });
- expect(actions.setSortingOptions).toHaveBeenCalledWith({ field: 'name', order: 'asc' });
- expect(actions.setPage).not.toHaveBeenCalled();
- expect(actions.setPerPage).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_rules_table_saved_state.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_rules_table_saved_state.test.ts
new file mode 100644
index 0000000000000..de9bf77e13236
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_rules_table_saved_state.test.ts
@@ -0,0 +1,762 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { renderHook } from '@testing-library/react-hooks';
+import { RULES_TABLE_MAX_PAGE_SIZE } from '../../../../../../common/constants';
+import type {
+ RulesTableStorageSavedState,
+ RulesTableUrlSavedState,
+} from './rules_table_saved_state';
+import { RuleSource } from './rules_table_saved_state';
+import { mockRulesTablePersistedState } from './__mocks__/mock_rules_table_persistent_state';
+import { useRulesTableSavedState } from './use_rules_table_saved_state';
+
+jest.mock('../../../../../common/lib/kibana');
+jest.mock('../../../../../common/utils/global_query_string/helpers');
+jest.mock('./rules_table_context');
+
+describe('useRulesTableSavedState', () => {
+ const urlSavedState: RulesTableUrlSavedState = {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ field: 'name',
+ order: 'asc',
+ page: 2,
+ perPage: 10,
+ };
+ const storageSavedState: RulesTableStorageSavedState = {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: false,
+ field: 'name',
+ order: 'asc',
+ perPage: 20,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when the state is not saved', () => {
+ beforeEach(() => {
+ mockRulesTablePersistedState({ urlState: null, storageState: null });
+ });
+
+ it('does not return the state', () => {
+ const {
+ result: { current: currentResult },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(currentResult).toEqual({});
+ });
+ });
+
+ describe('when the state is saved in the url', () => {
+ beforeEach(() => {
+ mockRulesTablePersistedState({ urlState: urlSavedState, storageState: null });
+ });
+
+ it('returns the state', () => {
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: urlSavedState.searchTerm,
+ source: urlSavedState.source,
+ tags: urlSavedState.tags,
+ enabled: urlSavedState.enabled,
+ });
+ expect(sorting).toEqual({
+ field: urlSavedState.field,
+ order: urlSavedState.order,
+ });
+ expect(pagination).toEqual({
+ page: urlSavedState.page,
+ perPage: urlSavedState.perPage,
+ });
+ });
+
+ it('returns the state ignoring negative page size', () => {
+ mockRulesTablePersistedState({
+ urlState: { ...urlSavedState, perPage: -1 },
+ storageState: null,
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: urlSavedState.searchTerm,
+ source: urlSavedState.source,
+ tags: urlSavedState.tags,
+ enabled: urlSavedState.enabled,
+ });
+ expect(sorting).toEqual({
+ field: urlSavedState.field,
+ order: urlSavedState.order,
+ });
+ expect(pagination).toEqual({});
+ });
+
+ it('returns the state ignoring the page size larger than max allowed', () => {
+ mockRulesTablePersistedState({
+ urlState: { ...urlSavedState, perPage: RULES_TABLE_MAX_PAGE_SIZE + 1 },
+ storageState: null,
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: urlSavedState.searchTerm,
+ source: urlSavedState.source,
+ tags: urlSavedState.tags,
+ enabled: urlSavedState.enabled,
+ });
+ expect(sorting).toEqual({
+ field: urlSavedState.field,
+ order: urlSavedState.order,
+ });
+ expect(pagination).toEqual({
+ page: urlSavedState.page,
+ });
+ });
+ });
+
+ describe('when the partial state is saved in the url', () => {
+ it('returns only the search term', () => {
+ mockRulesTablePersistedState({ urlState: { searchTerm: 'test' }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: 'test',
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only show prebuilt rules filter', () => {
+ mockRulesTablePersistedState({
+ urlState: { source: RuleSource.Prebuilt },
+ storageState: null,
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ source: RuleSource.Prebuilt,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only show custom rules filter', () => {
+ mockRulesTablePersistedState({ urlState: { source: RuleSource.Custom }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ source: RuleSource.Custom,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only tags', () => {
+ mockRulesTablePersistedState({ urlState: { tags: ['test'] }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ tags: ['test'],
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only enabled state', () => {
+ mockRulesTablePersistedState({ urlState: { enabled: true }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ enabled: true,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only sorting field', () => {
+ mockRulesTablePersistedState({ urlState: { field: 'name' }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({ field: 'name' });
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only sorting order', () => {
+ mockRulesTablePersistedState({ urlState: { order: 'asc' }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({ order: 'asc' });
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only page number', () => {
+ mockRulesTablePersistedState({ urlState: { page: 10 }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({ page: 10 });
+ });
+
+ it('returns only page size', () => {
+ mockRulesTablePersistedState({ urlState: { perPage: 10 }, storageState: null });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({ perPage: 10 });
+ });
+ });
+
+ describe('when state is saved in the storage', () => {
+ beforeEach(() => {
+ mockRulesTablePersistedState({ urlState: null, storageState: storageSavedState });
+ });
+
+ it('returns the state', () => {
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: storageSavedState.searchTerm,
+ source: storageSavedState.source,
+ tags: storageSavedState.tags,
+ enabled: storageSavedState.enabled,
+ });
+ expect(sorting).toEqual({
+ field: storageSavedState.field,
+ order: storageSavedState.order,
+ });
+ expect(pagination).toEqual({
+ perPage: storageSavedState.perPage,
+ });
+ });
+
+ it('returns the state ignoring negative page size', () => {
+ mockRulesTablePersistedState({
+ urlState: null,
+ storageState: { ...storageSavedState, perPage: -1 },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: storageSavedState.searchTerm,
+ source: storageSavedState.source,
+ tags: storageSavedState.tags,
+ enabled: storageSavedState.enabled,
+ });
+ expect(sorting).toEqual({
+ field: storageSavedState.field,
+ order: storageSavedState.order,
+ });
+ expect(pagination).toEqual({});
+ });
+
+ it('returns the state ignoring the page size larger than max allowed', () => {
+ mockRulesTablePersistedState({
+ urlState: null,
+ storageState: { ...storageSavedState, perPage: RULES_TABLE_MAX_PAGE_SIZE + 1 },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: storageSavedState.searchTerm,
+ source: storageSavedState.source,
+ tags: storageSavedState.tags,
+ enabled: storageSavedState.enabled,
+ });
+ expect(sorting).toEqual({
+ field: storageSavedState.field,
+ order: storageSavedState.order,
+ });
+ expect(pagination).toEqual({});
+ });
+ });
+
+ describe('when partial state is saved in the storage', () => {
+ it('returns only the search term', () => {
+ mockRulesTablePersistedState({ urlState: null, storageState: { searchTerm: 'test' } });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: 'test',
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only show prebuilt rules filter', () => {
+ mockRulesTablePersistedState({
+ urlState: null,
+ storageState: { source: RuleSource.Prebuilt },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ source: RuleSource.Prebuilt,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only show custom rules filter', () => {
+ mockRulesTablePersistedState({ urlState: null, storageState: { source: RuleSource.Custom } });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ source: RuleSource.Custom,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only tags', () => {
+ mockRulesTablePersistedState({ urlState: null, storageState: { tags: ['test'] } });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ tags: ['test'],
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only enabled state', () => {
+ mockRulesTablePersistedState({ urlState: null, storageState: { enabled: true } });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ enabled: true,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only sorting field', () => {
+ mockRulesTablePersistedState({ urlState: null, storageState: { field: 'name' } });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({ field: 'name' });
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only sorting order', () => {
+ mockRulesTablePersistedState({ urlState: null, storageState: { order: 'asc' } });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({ order: 'asc' });
+ expect(pagination).toEqual({});
+ });
+
+ it('does not return the page number', () => {
+ mockRulesTablePersistedState({
+ urlState: null,
+ // @ts-expect-error Passing an invalid value for the test
+ storageState: { page: 10 },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({});
+ });
+
+ it('returns only page size', () => {
+ mockRulesTablePersistedState({ urlState: null, storageState: { perPage: 10 } });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({ perPage: 10 });
+ });
+ });
+
+ describe('when state is saved in the url and the storage', () => {
+ beforeEach(() => {
+ mockRulesTablePersistedState({ urlState: urlSavedState, storageState: storageSavedState });
+ });
+
+ it('returns the state from the url', () => {
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: urlSavedState.searchTerm,
+ source: urlSavedState.source,
+ tags: urlSavedState.tags,
+ enabled: urlSavedState.enabled,
+ });
+ expect(sorting).toEqual({
+ field: urlSavedState.field,
+ order: urlSavedState.order,
+ });
+ expect(pagination).toEqual({
+ page: urlSavedState.page,
+ perPage: urlSavedState.perPage,
+ });
+ });
+ });
+
+ describe('when partial state is saved in the url and in the storage', () => {
+ it('returns only the search term', () => {
+ mockRulesTablePersistedState({
+ urlState: { searchTerm: 'test' },
+ storageState: { field: 'name' },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: 'test',
+ });
+ expect(sorting).toEqual({
+ field: 'name',
+ });
+ expect(pagination).toEqual({});
+ });
+ });
+
+ describe('when there is invalid state in the url', () => {
+ it('does not return the filter', () => {
+ mockRulesTablePersistedState({
+ urlState: {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ // @ts-expect-error Passing an invalid value for the test
+ tags: [1, 2, 3],
+ enabled: true,
+ field: 'name',
+ order: 'asc',
+ page: 2,
+ perPage: 10,
+ },
+ storageState: null,
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({
+ field: 'name',
+ order: 'asc',
+ });
+ expect(pagination).toEqual({
+ page: 2,
+ perPage: 10,
+ });
+ });
+
+ it('does not return the sorting', () => {
+ mockRulesTablePersistedState({
+ urlState: {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ field: 'name',
+ // @ts-expect-error Passing an invalid value for the test
+ order: 'abc',
+ page: 2,
+ perPage: 10,
+ },
+ storageState: null,
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({
+ page: 2,
+ perPage: 10,
+ });
+ });
+
+ it('does not return the pagination', () => {
+ mockRulesTablePersistedState({
+ urlState: {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ field: 'name',
+ order: 'asc',
+ // @ts-expect-error Passing an invalid value for the test
+ page: 'aaa',
+ perPage: 10,
+ },
+ storageState: null,
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ });
+ expect(sorting).toEqual({
+ field: 'name',
+ order: 'asc',
+ });
+ expect(pagination).toEqual({});
+ });
+ });
+
+ describe('when there is invalid state in the storage', () => {
+ it('does not return the filter', () => {
+ mockRulesTablePersistedState({
+ urlState: null,
+ storageState: {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ // @ts-expect-error Passing an invalid value for the test
+ tags: [1, 2, 3],
+ enabled: true,
+ field: 'name',
+ order: 'asc',
+ perPage: 10,
+ },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({});
+ expect(sorting).toEqual({
+ field: 'name',
+ order: 'asc',
+ });
+ expect(pagination).toEqual({
+ perPage: 10,
+ });
+ });
+
+ it('does not return the sorting', () => {
+ mockRulesTablePersistedState({
+ urlState: null,
+ storageState: {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ field: 'name',
+ // @ts-expect-error Passing an invalid value for the test
+ order: 'abc',
+ perPage: 10,
+ },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ });
+ expect(sorting).toEqual({});
+ expect(pagination).toEqual({
+ perPage: 10,
+ });
+ });
+
+ it('does not return the pagination', () => {
+ mockRulesTablePersistedState({
+ urlState: null,
+ storageState: {
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ field: 'name',
+ order: 'asc',
+ // @ts-expect-error Passing an invalid value for the test
+ perPage: 'aaa',
+ },
+ });
+
+ const {
+ result: {
+ current: { filter, sorting, pagination },
+ },
+ } = renderHook(() => useRulesTableSavedState());
+
+ expect(filter).toEqual({
+ searchTerm: 'test',
+ source: RuleSource.Custom,
+ tags: ['test'],
+ enabled: true,
+ });
+ expect(sorting).toEqual({
+ field: 'name',
+ order: 'asc',
+ });
+ expect(pagination).toEqual({});
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_initialize_rules_table_saved_state.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_rules_table_saved_state.ts
similarity index 56%
rename from x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_initialize_rules_table_saved_state.ts
rename to x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_rules_table_saved_state.ts
index d70547dc74548..5cc9fe604ce7f 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_initialize_rules_table_saved_state.ts
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/use_rules_table_saved_state.ts
@@ -5,7 +5,6 @@
* 2.0.
*/
-import { useEffect } from 'react';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { validateNonExact } from '@kbn/securitysolution-io-ts-utils';
import { useGetInitialUrlParamValue } from '../../../../../common/utils/global_query_string/helpers';
@@ -13,19 +12,16 @@ import { RULES_TABLE_MAX_PAGE_SIZE } from '../../../../../../common/constants';
import { useKibana } from '../../../../../common/lib/kibana';
import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state';
import { RULES_TABLE_STATE_STORAGE_KEY } from '../constants';
-import { useRulesTableContext } from './rules_table_context';
import type {
RulesTableStorageSavedState,
RulesTableUrlSavedState,
} from './rules_table_saved_state';
import {
- RuleSource,
RulesTableSavedFilter,
RulesTableStorageSavedPagination,
RulesTableUrlSavedPagination,
RulesTableSavedSorting,
} from './rules_table_saved_state';
-import { DEFAULT_FILTER_OPTIONS, DEFAULT_SORTING_OPTIONS } from './rules_table_defaults';
function readStorageState(storage: Storage): RulesTableStorageSavedState | null {
try {
@@ -35,67 +31,64 @@ function readStorageState(storage: Storage): RulesTableStorageSavedState | null
}
}
+export function useRulesTableSavedState(): {
+ filter?: RulesTableSavedFilter;
+ sorting?: RulesTableSavedSorting;
+ pagination?: RulesTableUrlSavedPagination;
+} {
+ const getUrlParam = useGetInitialUrlParamValue(URL_PARAM_KEY.rulesTable);
+ const {
+ services: { sessionStorage },
+ } = useKibana();
+
+ const urlState = getUrlParam();
+ const storageState = readStorageState(sessionStorage);
+
+ if (!urlState && !storageState) {
+ return {};
+ }
+
+ const [filter, sorting, pagination] = validateState(urlState, storageState);
+
+ return { filter, sorting, pagination };
+}
+
function validateState(
urlState: RulesTableUrlSavedState | null,
storageState: RulesTableStorageSavedState | null
): [RulesTableSavedFilter, RulesTableSavedSorting, RulesTableUrlSavedPagination] {
const [filterFromUrl] = validateNonExact(urlState, RulesTableSavedFilter);
const [filterFromStorage] = validateNonExact(storageState, RulesTableSavedFilter);
- const filter = { ...filterFromStorage, ...filterFromUrl };
+ // We have to expose filter, sorting and pagination objects by explicitly specifying each field
+ // since urlState and/or storageState may contain unnecessary fields (e.g. outdated or explicitly added by user)
+ // and validateNonExact doesn't truncate fields not included in the type RulesTableSavedFilter and etc.
+ const filter: RulesTableSavedFilter = {
+ searchTerm: filterFromUrl?.searchTerm ?? filterFromStorage?.searchTerm,
+ source: filterFromUrl?.source ?? filterFromStorage?.source,
+ tags: filterFromUrl?.tags ?? filterFromStorage?.tags,
+ enabled: filterFromUrl?.enabled ?? filterFromStorage?.enabled,
+ };
const [sortingFromUrl] = validateNonExact(urlState, RulesTableSavedSorting);
const [sortingFromStorage] = validateNonExact(storageState, RulesTableSavedSorting);
- const sorting = { ...sortingFromStorage, ...sortingFromUrl };
+ const sorting = {
+ field: sortingFromUrl?.field ?? sortingFromStorage?.field,
+ order: sortingFromUrl?.order ?? sortingFromStorage?.order,
+ };
const [paginationFromUrl] = validateNonExact(urlState, RulesTableUrlSavedPagination);
const [paginationFromStorage] = validateNonExact(storageState, RulesTableStorageSavedPagination);
- const pagination = { perPage: paginationFromStorage?.perPage, ...paginationFromUrl };
-
- return [filter, sorting, pagination];
-}
-
-export function useInitializeRulesTableSavedState(): void {
- const getUrlParam = useGetInitialUrlParamValue(URL_PARAM_KEY.rulesTable);
- const { actions } = useRulesTableContext();
- const {
- services: { sessionStorage },
- } = useKibana();
-
- useEffect(() => {
- const urlState = getUrlParam();
- const storageState = readStorageState(sessionStorage);
+ const pagination = {
+ page: paginationFromUrl?.page, // We don't persist page number in the session storage since it may be outdated when restored
+ perPage: paginationFromUrl?.perPage ?? paginationFromStorage?.perPage,
+ };
- if (!urlState && !storageState) {
- return;
- }
-
- const [filter, sorting, pagination] = validateState(urlState, storageState);
-
- actions.setFilterOptions({
- filter: filter.searchTerm ?? DEFAULT_FILTER_OPTIONS.filter,
- showElasticRules: filter.source === RuleSource.Prebuilt,
- showCustomRules: filter.source === RuleSource.Custom,
- tags: Array.isArray(filter.tags) ? filter.tags : DEFAULT_FILTER_OPTIONS.tags,
- enabled: filter.enabled,
- });
-
- if (sorting.field || sorting.order) {
- actions.setSortingOptions({
- field: sorting.field ?? DEFAULT_SORTING_OPTIONS.field,
- order: sorting.order ?? DEFAULT_SORTING_OPTIONS.order,
- });
- }
-
- if (pagination.page) {
- actions.setPage(pagination.page);
- }
+ if (
+ pagination.perPage &&
+ (pagination.perPage < 0 || pagination.perPage > RULES_TABLE_MAX_PAGE_SIZE)
+ ) {
+ delete pagination.perPage;
+ }
- if (
- pagination.perPage &&
- pagination.perPage > 0 &&
- pagination.perPage <= RULES_TABLE_MAX_PAGE_SIZE
- ) {
- actions.setPerPage(pagination.perPage);
- }
- }, [getUrlParam, actions, sessionStorage]);
+ return [filter, sorting, pagination];
}
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_utility_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_utility_bar.tsx
index 7c91e27423697..9c681d95ae520 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_utility_bar.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_utility_bar.tsx
@@ -179,6 +179,16 @@ export const RulesTableUtilityBar = React.memo(
>
{i18n.REFRESH_RULE_POPOVER_LABEL}
+ {!rulesTableContext.state.isDefault && (
+
+ {i18n.CLEAR_RULES_TABLE_FILTERS}
+
+ )}
>
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
index 0e1c1b1c0b43e..c71cc4cf267a4 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
@@ -889,6 +889,13 @@ export const REFRESH_RULE_POPOVER_LABEL = i18n.translate(
}
);
+export const CLEAR_RULES_TABLE_FILTERS = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.rules.clearRulesTableFilters',
+ {
+ defaultMessage: 'Clear filters',
+ }
+);
+
/**
* Bulk Export
*/
From 78b16cbe12cbb3f34004b8260502dea155076dbc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 6 Feb 2023 16:41:29 -0600
Subject: [PATCH 024/134] Update dependency @babel/generator to ^7.20.14 (main)
(#150262)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [@babel/generator](https://babel.dev/docs/en/next/babel-generator)
([source](https://togithub.com/babel/babel)) | [`^7.20.7` ->
`^7.20.14`](https://renovatebot.com/diffs/npm/@babel%2fgenerator/7.20.7/7.20.14)
|
[![age](https://badges.renovateapi.com/packages/npm/@babel%2fgenerator/7.20.14/age-slim)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://badges.renovateapi.com/packages/npm/@babel%2fgenerator/7.20.14/adoption-slim)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://badges.renovateapi.com/packages/npm/@babel%2fgenerator/7.20.14/compatibility-slim/7.20.7)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://badges.renovateapi.com/packages/npm/@babel%2fgenerator/7.20.14/confidence-slim/7.20.7)](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
babel/babel
###
[`v7.20.14`](https://togithub.com/babel/babel/blob/HEAD/CHANGELOG.md#v72014-2023-01-27)
[Compare
Source](https://togithub.com/babel/babel/compare/v7.20.7...v7.20.14)
##### :bug: Bug Fix
- `babel-plugin-transform-block-scoping`
- [#15361](https://togithub.com/babel/babel/pull/15361) fix:
Identifiers in the loop are not renamed
([@liuxingbaoyu](https://togithub.com/liuxingbaoyu))
- `babel-cli`, `babel-core`, `babel-generator`,
`babel-helper-transform-fixture-test-runner`,
`babel-plugin-transform-destructuring`,
`babel-plugin-transform-modules-commonjs`,
`babel-plugin-transform-react-jsx`, `babel-traverse`
- [#15365](https://togithub.com/babel/babel/pull/15365) fix:
Properly generate source maps for manually added multi-line content
([@liuxingbaoyu](https://togithub.com/liuxingbaoyu))
---
### Configuration
📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.
♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR has been generated by [Mend
Renovate](https://www.mend.io/free-developer-tools/renovate/). View
repository job log
[here](https://app.renovatebot.com/dashboard#github/elastic/kibana).
---------
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Budzenski
---
package.json | 2 +-
yarn.lock | 12 ++++++------
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/package.json b/package.json
index c2613ebda6e76..d26b6bd79b0c2 100644
--- a/package.json
+++ b/package.json
@@ -669,7 +669,7 @@
"@babel/core": "^7.20.12",
"@babel/eslint-parser": "^7.19.1",
"@babel/eslint-plugin": "^7.19.1",
- "@babel/generator": "^7.20.7",
+ "@babel/generator": "^7.20.14",
"@babel/helper-plugin-utils": "^7.20.2",
"@babel/parser": "^7.20.13",
"@babel/plugin-proposal-class-properties": "^7.18.6",
diff --git a/yarn.lock b/yarn.lock
index 77b529bdd0bcb..fa46dea2940e9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -150,10 +150,10 @@
dependencies:
eslint-rule-composer "^0.3.0"
-"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.20.7", "@babel/generator@^7.7.2":
- version "7.20.7"
- resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.7.tgz#f8ef57c8242665c5929fe2e8d82ba75460187b4a"
- integrity sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==
+"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.20.14", "@babel/generator@^7.20.7", "@babel/generator@^7.7.2":
+ version "7.20.14"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.14.tgz#9fa772c9f86a46c6ac9b321039400712b96f64ce"
+ integrity sha512-AEmuXHdcD3A52HHXxaTmYlb8q/xMEhoRP67B3T4Oq7lbmSoqroMZzjnGj3+i1io3pdnF8iBYVu4Ilj+c4hBxYg==
dependencies:
"@babel/types" "^7.20.7"
"@jridgewell/gen-mapping" "^0.3.2"
@@ -28475,12 +28475,12 @@ write-file-atomic@^4.0.1:
imurmurhash "^0.1.4"
signal-exit "^3.0.7"
-ws@8.9.0, ws@^8.2.3, ws@^8.4.2, ws@^8.9.0:
+ws@8.9.0:
version "8.9.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e"
integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==
-ws@>=8.11.0:
+ws@>=8.11.0, ws@^8.2.3, ws@^8.4.2, ws@^8.9.0:
version "8.12.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.0.tgz#485074cc392689da78e1828a9ff23585e06cddd8"
integrity sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==
From 55b66e20fecb39ff0026c53c3ae6b3f8242be734 Mon Sep 17 00:00:00 2001
From: Hannah Mudge
Date: Mon, 6 Feb 2023 16:13:28 -0700
Subject: [PATCH 025/134] [Dashboard] [Controls] Load more options list
suggestions on scroll (#148331)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes https://github.com/elastic/kibana/issues/140175
Closes https://github.com/elastic/kibana/issues/143580
## Summary
Oh, boy! Get ready for a doozy of a PR, folks! Let's talk about the
three major things that were accomplished here:
### 1) Pagination
Originally, this PR was meant to add traditional pagination to the
options list control. However, after implementing a version of this, it
became apparent that, not only was UI becoming uncomfortably messy, it
also had some UX concerns because we were deviating from the usual
pagination pattern by showing the cardinality rather than the number of
pages:
So, instead of traditional pagination, we decided to take a different
approach (which was made possible by
https://github.com/elastic/kibana/pull/148420) - **load more options
when the user scrolls to the bottom!** Here it is in action:
It is important that the first query remains **fast** - that is why we
still only request the top 10 options when the control first loads. So,
having a "load more" is the best approach that allows users to see more
suggestions while also ensuring that the performance of options lists
(especially with respect to chaining) is not impacted.
Note that it is **not possible** to grab every single value of a field -
the limit is `10,000`. However, since it is impractical that a user
would want to scroll through `10,000` suggestions (and potentially very
slow to fetch), we have instead made the limit of this "show more"
functionality `1,000`. To make this clear, if the field has more than
`1,000` values and the user scrolls all the way to the bottom, they will
get the following message:
### 2) Cardinality
Previously, the cardinality of the options list control was **only**
shown as part of the control placeholder text - this meant that, once
the user entered their search term, they could no longer see the
cardinality of the returned options. This PR changes this functionality
by placing the cardinality in a badge **beside** the search bar - this
value now changes as the user types, so they can very clearly see how
many options match their search:
> **Note**
> After some initial feedback, we have removed both the cardinality and
invalid selections badges in favour of displaying the cardinality below
the search bar, like so:
>
>
>
> So, please be aware that the screenshots above are outdated.
### 3) Changes to Queries
This is where things get.... messy! Essentially, our previous queries
were all built with the expectation that the Elasticsearch setting
`search.allow_expensive_queries` was **off** - this meant that they
worked regardless of the value of this setting. However, when trying to
get the cardinality to update based on a search term, it became apparent
that this was not possible if we kept the same assumptions -
specifically, if `search.allow_expensive_queries` is off, there is
absolutely no way for the cardinality of **keyword only fields** to
respond to a search term.
After a whole lot of discussion, we decided that the updating
cardinality was a feature important enough to justify having **two
separate versions** of the queries:
1. **Queries for when `search.allow_expensive_queries` is off**:
These are essentially the same as our old queries - however, since we
can safely assume that this setting is **usually** on (it defaults on,
and there is no UI to easily change it), we opted to simplify them a
bit.
First of all, we used to create a special object for tracking the
parent/child relationship of fields that are mapped as keyword+text -
this was so that, if a user created a control on these fields, we could
support case-insensitive search. We no longer do this - if
`search.allow_expensive_queries` is off and you create a control on a
text+keyword field, the search will be case sensitive. This helps clean
up our code quite a bit.
Second, we are no longer returning **any** cardinality. Since the
cardinality is now displayed as a badge beside the search bar, users
would expect that this value would change as they type - however, since
it's impossible to make this happen for keyword-only fields and to keep
behaviour consistent, we have opted to simply remove this badge when
`search.allow_expensive_queries` is off **regardless** of the field
type. So, there is no longer a need to include the `cardinality` query
when grabbing the suggestions.
Finally, we do not support "load more" when
`search.allow_expensive_queries` is off. While this would theoretically
be possible, because we are no longer grabbing the cardinality, we would
have to always fetch `1,000` results when the user loads more, even if
the true cardinality is much smaller. Again, we are pretty confident
that **more often than not**, the `search.allow_expensive_queries` is
on; therefore, we are choosing to favour developer experience in this
instance because the impact should be quite small.
2. **Queries for when `search.allow_expensive_queries` is on**:
When this setting is on, we now have access to the prefix query, which
greatly simplifies how our queries are handled - now, rather than having
separate queries for keyword-only, keyword+text, and nested fields,
these have all been combined into a single query! And even better -
:star: now **all** string-based fields support case-insensitive search!
:star: Yup, that's right - even keyword-only fields 💃
There has been [discussion on the Elasticsearch side
](https://github.com/elastic/elasticsearch/issues/90898) about whether
or not this setting is even **practical**, and so it is possible that,
in the near future, this distinction will no longer be necessary. With
this in mind, I have made these two versions of our queries **completely
separate** from each other - while this introduces some code
duplication, it makes the cleanup that may follow much, much easier.
Well, that was sure fun, hey?
## How to Test
I've created a quick little Python program to ingest some good testing
data for this PR:
```python
import random
import time
import pandas as pd
from faker import Faker
from elasticsearch import Elasticsearch
SIZE = 10000
ELASTIC_PASSWORD = "changeme"
INDEX_NAME = 'test_large_index'
Faker.seed(time.time())
faker = Faker()
hundredRandomSentences = [faker.sentence(random.randint(5, 35)) for _ in range(100)]
thousandRandomIps = [faker.ipv4() if random.randint(0, 99) < 50 else faker.ipv6() for _ in range(1000)]
client = Elasticsearch(
"http://localhost:9200",
basic_auth=("elastic", ELASTIC_PASSWORD),
)
if(client.indices.exists(index=INDEX_NAME)):
client.indices.delete(index=INDEX_NAME)
client.indices.create(index=INDEX_NAME, mappings={"properties":{"keyword_field":{"type":"keyword"},"id":{"type":"long"},"ip_field":{"type":"ip"},"boolean_field":{"type":"boolean"},"keyword_text_field":{"type":"text","fields":{"keyword":{"type":"keyword"}}},"nested_field":{"type":"nested","properties":{"first":{"type":"text","fields":{"keyword":{"type":"keyword"}}},"last":{"type":"text","fields":{"keyword":{"type":"keyword"}}}}},"long_keyword_text_field":{"type":"text","fields":{"keyword":{"type":"keyword"}}}}})
print('Generating data', end='')
for i in range(SIZE):
name1 = faker.name();
[first_name1, last_name1] = name1.split(' ', 1)
name2 = faker.name();
[first_name2, last_name2] = name2.split(' ', 1)
response = client.create(index=INDEX_NAME, id=i, document={
'keyword_field': faker.country(),
'id': i,
'boolean_field': faker.boolean(),
'ip_field': thousandRandomIps[random.randint(0, 999)],
'keyword_text_field': faker.name(),
'nested_field': [
{ 'first': first_name1, 'last': last_name1},
{ 'first': first_name2, 'last': last_name2}
],
'long_keyword_text_field': hundredRandomSentences[random.randint(0, 99)]
})
print('.', end='')
print(' Done!')
```
However, if you don't have Python up and running, here's a CSV with a
smaller version of this data:
[testNewQueriesData.csv](https://github.com/elastic/kibana/files/10538537/testNewQueriesData.csv)
> **Warning**
> When uploading, make sure to update the mappings of the CSV data to
the mappings included as part of the Python script above (which you can
find as part of the `client.indices.create` call). You'll notice,
however, that **none of the CSV documents have a nested field**.
Unfortunately, there doesn't seem to be a way to able to ingest nested
data through uploading a CSV, so the above data does not include one -
in order to test the nested data type, you'd have to add some of your
own documents
>
> Here's a sample nested field document, for your convenience:
> ```json
> {
> "keyword_field": "Russian Federation",
> "id": 0,
> "boolean_field": true,
> "ip_field": "121.149.70.251",
> "keyword_text_field": "Michael Foster",
> "nested_field": [
> {
> "first": "Rachel",
> "last": "Wright"
> },
> {
> "first": "Gary",
> "last": "Reyes"
> }
> ],
> "long_keyword_text_field": "Color hotel indicate appear since well
sure right yet individual easy often test enough left a usually
attention."
> }
> ```
>
### Testing Notes
Because there are now two versions of the queries, thorough testing
should be done for both when `search.allow_expensive_queries` is `true`
and when it is `false` for every single field type that is currently
supported. Use the following call to the cluster settings API to toggle
this value back and forth:
```php
PUT _cluster/settings
{
"transient": {
"search.allow_expensive_queries": // true or false
}
}
```
You should pay super special attention to the behaviour that happens
when toggling this value from `true` to `false` - for example, consider
the following:
1. Ensure `search.allow_expensive_queries` is either `true` or
`undefined`
2. Create and save a dashboard with at least one options list control
3. Navigate to the console and set `search.allow_expensive_queries` to
`false` - **DO NOT REFRESH**
4. Go back to the dashboard
5. Open up the options list control you created in step 2
6. Fetch a new, uncached request, either by scrolling to the bottom and
fetching more (assuming these values aren't already in the cache) or by
performing a search with a string you haven't tried before
7. ⚠️ **The options list control _should_ have a fatal error** ⚠️ The
Elasticsearch server knows that `search.allow_expensive_queries` is now
`false` but, because we only fetch this value on the first load on the
client side, it has not yet been updated - this means the options list
service still tries to fetch the suggestions using the expensive version
of the queries despite the fact that Elasticsearch will now reject this
request. The most graceful way to handle this is to simply throw a fatal
error.
8. Refreshing the browser will make things sync up again and you should
now get the expected results when opening the options list control.
### Flaky Test Runner
### Checklist
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
> **Note**
> Technically, it actually does - however, it is due to an [EUI
bug](https://github.com/elastic/eui/issues/6565) from adding the group
label to the bottom of the list.
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
---
.../controls/common/options_list/types.ts | 39 +-
src/plugins/controls/common/types.ts | 2 -
.../public/__stories__/controls.stories.tsx | 1 -
.../component/control_frame_component.tsx | 31 +-
.../control_group/editor/control_editor.tsx | 3 -
.../editor/data_control_editor_tools.ts | 53 +-
.../options_list/components/options_list.scss | 31 +-
.../components/options_list_control.test.tsx | 1 +
.../components/options_list_control.tsx | 33 +-
.../components/options_list_popover.test.tsx | 30 +-
.../components/options_list_popover.tsx | 56 +-
.../options_list_popover_action_bar.tsx | 152 ++--
.../options_list_popover_footer.tsx | 41 +-
.../options_list_popover_sorting_button.tsx | 43 +-
.../options_list_popover_suggestions.tsx | 140 ++--
.../components/options_list_popover_title.tsx | 46 ++
.../components/options_list_strings.ts | 32 +-
.../embeddable/options_list_embeddable.tsx | 113 +--
.../options_list/options_list_reducers.ts | 20 +-
.../controls/public/options_list/types.ts | 14 +-
.../public/services/http/http.stub.ts | 1 +
.../public/services/http/http_service.ts | 3 +-
.../controls/public/services/http/types.ts | 1 +
.../options_list/options_list.story.ts | 11 +-
.../options_list/options_list_service.ts | 31 +-
.../public/services/options_list/types.ts | 11 +-
src/plugins/controls/public/types.ts | 2 -
...ons_list_cheap_suggestion_queries.test.ts} | 507 ++++++-------
.../options_list_cheap_suggestion_queries.ts | 201 ++++++
.../options_list_cluster_settings_route.ts | 47 ++
..._list_expensive_suggestion_queries.test.ts | 676 ++++++++++++++++++
...tions_list_expensive_suggestion_queries.ts | 217 ++++++
.../options_list/options_list_queries.ts | 289 --------
.../options_list_suggestion_query_helpers.ts | 38 +
.../options_list_suggestions_route.ts | 41 +-
.../options_list_validation_queries.test.ts | 104 +++
.../options_list_validation_queries.ts | 43 ++
.../controls/server/options_list/types.ts | 28 +
src/plugins/controls/server/plugin.ts | 3 +-
src/plugins/controls/tsconfig.json | 1 +
.../embeddable/dashboard_container.tsx | 2 +-
.../dashboard_control_group_integration.ts | 11 +-
.../lib/embeddables/error_embedabble.scss | 10 -
.../lib/embeddables/error_embeddable.scss | 20 +
.../lib/embeddables/error_embeddable.tsx | 1 +
.../controls/options_list/index.ts | 22 +-
...ptions_list_allow_expensive_queries_off.ts | 96 +++
.../page_objects/dashboard_page_controls.ts | 13 +
.../translations/translations/fr-FR.json | 2 -
.../translations/translations/ja-JP.json | 2 -
.../translations/translations/zh-CN.json | 2 -
51 files changed, 2374 insertions(+), 943 deletions(-)
create mode 100644 src/plugins/controls/public/options_list/components/options_list_popover_title.tsx
rename src/plugins/controls/server/options_list/{options_list_queries.test.ts => options_list_cheap_suggestion_queries.test.ts} (57%)
create mode 100644 src/plugins/controls/server/options_list/options_list_cheap_suggestion_queries.ts
create mode 100644 src/plugins/controls/server/options_list/options_list_cluster_settings_route.ts
create mode 100644 src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.test.ts
create mode 100644 src/plugins/controls/server/options_list/options_list_expensive_suggestion_queries.ts
delete mode 100644 src/plugins/controls/server/options_list/options_list_queries.ts
create mode 100644 src/plugins/controls/server/options_list/options_list_suggestion_query_helpers.ts
create mode 100644 src/plugins/controls/server/options_list/options_list_validation_queries.test.ts
create mode 100644 src/plugins/controls/server/options_list/options_list_validation_queries.ts
create mode 100644 src/plugins/controls/server/options_list/types.ts
delete mode 100644 src/plugins/embeddable/public/lib/embeddables/error_embedabble.scss
create mode 100644 src/plugins/embeddable/public/lib/embeddables/error_embeddable.scss
create mode 100644 test/functional/apps/dashboard_elements/controls/options_list/options_list_allow_expensive_queries_off.ts
diff --git a/src/plugins/controls/common/options_list/types.ts b/src/plugins/controls/common/options_list/types.ts
index 1c9555bdc8d90..510dac280fe76 100644
--- a/src/plugins/controls/common/options_list/types.ts
+++ b/src/plugins/controls/common/options_list/types.ts
@@ -9,8 +9,8 @@
import { FieldSpec, DataView, RuntimeFieldSpec } from '@kbn/data-views-plugin/common';
import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query';
-import { OptionsListSortingType } from './suggestions_sorting';
-import { DataControlInput } from '../types';
+import type { OptionsListSortingType } from './suggestions_sorting';
+import type { DataControlInput } from '../types';
export const OPTIONS_LIST_CONTROL = 'optionsListControl';
@@ -20,20 +20,14 @@ export interface OptionsListEmbeddableInput extends DataControlInput {
existsSelected?: boolean;
runPastTimeout?: boolean;
singleSelect?: boolean;
+ hideActionBar?: boolean;
hideExclude?: boolean;
hideExists?: boolean;
hideSort?: boolean;
- hideActionBar?: boolean;
exclude?: boolean;
placeholder?: string;
}
-export type OptionsListField = FieldSpec & {
- textFieldName?: string;
- parentFieldName?: string;
- childFieldName?: string;
-};
-
export interface OptionsListSuggestions {
[key: string]: { doc_count: number };
}
@@ -41,13 +35,27 @@ export interface OptionsListSuggestions {
/**
* The Options list response is returned from the serverside Options List route.
*/
-export interface OptionsListResponse {
- rejected: boolean;
+export interface OptionsListSuccessResponse {
suggestions: OptionsListSuggestions;
- totalCardinality: number;
+ totalCardinality?: number; // total cardinality will be undefined when `useExpensiveQueries` is `false`
invalidSelections?: string[];
}
+/**
+ * The invalid selections are parsed **after** the server returns with the result from the ES client; so, the
+ * suggestion aggregation parser only returns the suggestions list + the cardinality of the result
+ */
+export type OptionsListParsedSuggestions = Pick<
+ OptionsListSuccessResponse,
+ 'suggestions' | 'totalCardinality'
+>;
+
+export interface OptionsListFailureResponse {
+ error: 'aborted' | Error;
+}
+
+export type OptionsListResponse = OptionsListSuccessResponse | OptionsListFailureResponse;
+
/**
* The Options list request type taken in by the public Options List service.
*/
@@ -55,11 +63,12 @@ export type OptionsListRequest = Omit<
OptionsListRequestBody,
'filters' | 'fieldName' | 'fieldSpec' | 'textFieldName'
> & {
+ allowExpensiveQueries: boolean;
timeRange?: TimeRange;
- field: OptionsListField;
runPastTimeout?: boolean;
dataView: DataView;
filters?: Filter[];
+ field: FieldSpec;
query?: Query;
};
@@ -68,13 +77,13 @@ export type OptionsListRequest = Omit<
*/
export interface OptionsListRequestBody {
runtimeFieldMap?: Record;
+ allowExpensiveQueries: boolean;
sort?: OptionsListSortingType;
filters?: Array<{ bool: BoolQuery }>;
selectedOptions?: string[];
runPastTimeout?: boolean;
- parentFieldName?: string;
- textFieldName?: string;
searchString?: string;
fieldSpec?: FieldSpec;
fieldName: string;
+ size: number;
}
diff --git a/src/plugins/controls/common/types.ts b/src/plugins/controls/common/types.ts
index 8f03b82bfaa93..5f37ef2c72871 100644
--- a/src/plugins/controls/common/types.ts
+++ b/src/plugins/controls/common/types.ts
@@ -30,7 +30,5 @@ export type ControlInput = EmbeddableInput & {
export type DataControlInput = ControlInput & {
fieldName: string;
- parentFieldName?: string;
- childFieldName?: string;
dataViewId: string;
};
diff --git a/src/plugins/controls/public/__stories__/controls.stories.tsx b/src/plugins/controls/public/__stories__/controls.stories.tsx
index 6b1f97e39ed7e..4326ce056d118 100644
--- a/src/plugins/controls/public/__stories__/controls.stories.tsx
+++ b/src/plugins/controls/public/__stories__/controls.stories.tsx
@@ -61,7 +61,6 @@ const storybookStubOptionsListRequest = async (
{}
),
totalCardinality: 100,
- rejected: false,
}),
120
)
diff --git a/src/plugins/controls/public/control_group/component/control_frame_component.tsx b/src/plugins/controls/public/control_group/component/control_frame_component.tsx
index 31a63f2fd4eff..eb5cda421381d 100644
--- a/src/plugins/controls/public/control_group/component/control_frame_component.tsx
+++ b/src/plugins/controls/public/control_group/component/control_frame_component.tsx
@@ -9,15 +9,13 @@
import React, { useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import {
+ EuiButtonEmpty,
EuiButtonIcon,
EuiFormControlLayout,
EuiFormLabel,
EuiFormRow,
- EuiIcon,
- EuiLink,
EuiLoadingChart,
EuiPopover,
- EuiText,
EuiToolTip,
} from '@elastic/eui';
@@ -40,25 +38,26 @@ interface ControlFrameErrorProps {
const ControlFrameError = ({ error }: ControlFrameErrorProps) => {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const popoverButton = (
-
- setPopoverOpen((open) => !open)}
- >
-
-
-
-
+ setPopoverOpen((open) => !open)}
+ className={'errorEmbeddableCompact__button'}
+ textProps={{ className: 'errorEmbeddableCompact__text' }}
+ >
+
+
);
return (
setPopoverOpen(false)}
>
diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx
index bff1506280653..785e0e50bd78a 100644
--- a/src/plugins/controls/public/control_group/editor/control_editor.tsx
+++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx
@@ -199,11 +199,8 @@ export const ControlEditor = ({
selectedFieldName={selectedField}
dataView={dataView}
onSelectField={(field) => {
- const { parentFieldName, childFieldName } = fieldRegistry?.[field.name] ?? {};
onTypeEditorChange({
fieldName: field.name,
- ...(parentFieldName && { parentFieldName }),
- ...(childFieldName && { childFieldName }),
});
const newDefaultTitle = field.displayName ?? field.name;
setDefaultTitle(newDefaultTitle);
diff --git a/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts
index 4344891280ce6..8cfd3cebe5dac 100644
--- a/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts
+++ b/src/plugins/controls/public/control_group/editor/data_control_editor_tools.ts
@@ -8,11 +8,10 @@
import { memoize } from 'lodash';
-import { IFieldSubTypeMulti } from '@kbn/es-query';
import { DataView } from '@kbn/data-views-plugin/common';
import { pluginServices } from '../../services';
-import { DataControlFieldRegistry, IEditableControlFactory } from '../../types';
+import { DataControlField, DataControlFieldRegistry, IEditableControlFactory } from '../../types';
export const getDataControlFieldRegistry = memoize(
async (dataView: DataView) => {
@@ -21,50 +20,30 @@ export const getDataControlFieldRegistry = memoize(
(dataView: DataView) => [dataView.id, JSON.stringify(dataView.fields.getAll())].join('|')
);
-const doubleLinkFields = (dataView: DataView) => {
- // double link the parent-child relationship specifically for case-sensitivity support for options lists
- const fieldRegistry: DataControlFieldRegistry = {};
-
- for (const field of dataView.fields.getAll()) {
- if (!fieldRegistry[field.name]) {
- fieldRegistry[field.name] = { field, compatibleControlTypes: [] };
- }
-
- const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent;
- if (parentFieldName) {
- fieldRegistry[field.name].parentFieldName = parentFieldName;
-
- const parentField = dataView.getFieldByName(parentFieldName);
- if (!fieldRegistry[parentFieldName] && parentField) {
- fieldRegistry[parentFieldName] = { field: parentField, compatibleControlTypes: [] };
- }
- fieldRegistry[parentFieldName].childFieldName = field.name;
- }
- }
- return fieldRegistry;
-};
-
const loadFieldRegistryFromDataView = async (
dataView: DataView
): Promise => {
const {
controls: { getControlTypes, getControlFactory },
} = pluginServices.getServices();
- const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(dataView);
+
const controlFactories = getControlTypes().map(
(controlType) => getControlFactory(controlType) as IEditableControlFactory
);
- dataView.fields.map((dataViewField) => {
- for (const factory of controlFactories) {
- if (factory.isFieldCompatible) {
- factory.isFieldCompatible(newFieldRegistry[dataViewField.name]);
+ const fieldRegistry: DataControlFieldRegistry = dataView.fields
+ .getAll()
+ .reduce((registry, field) => {
+ const test: DataControlField = { field, compatibleControlTypes: [] };
+ for (const factory of controlFactories) {
+ if (factory.isFieldCompatible) {
+ factory.isFieldCompatible(test);
+ }
}
- }
-
- if (newFieldRegistry[dataViewField.name]?.compatibleControlTypes.length === 0) {
- delete newFieldRegistry[dataViewField.name];
- }
- });
+ if (test.compatibleControlTypes.length === 0) {
+ return { ...registry };
+ }
+ return { ...registry, [field.name]: test };
+ }, {});
- return newFieldRegistry;
+ return fieldRegistry;
};
diff --git a/src/plugins/controls/public/options_list/components/options_list.scss b/src/plugins/controls/public/options_list/components/options_list.scss
index e88208ee4c623..ad042916fff6e 100644
--- a/src/plugins/controls/public/options_list/components/options_list.scss
+++ b/src/plugins/controls/public/options_list/components/options_list.scss
@@ -9,8 +9,18 @@
.optionsList__actions {
padding: $euiSizeS;
+ padding-bottom: 0;
border-bottom: $euiBorderThin;
border-color: darken($euiColorLightestShade, 2%);
+
+ .optionsList__actionsRow {
+ margin: ($euiSizeS / 2) 0 !important;
+
+ .optionsList__actionBarDivider {
+ height: $euiSize;
+ border-right: $euiBorderThin;
+ }
+ }
}
.optionsList__popoverTitle {
@@ -30,13 +40,17 @@
font-style: italic;
}
+.optionsList__loadMore {
+ font-style: italic;
+}
+
.optionsList__negateLabel {
font-weight: bold;
font-size: $euiSizeM;
color: $euiColorDanger;
}
-.optionsList__ignoredBadge {
+.optionsList__actionBarFirstBadge {
margin-left: $euiSizeS;
}
@@ -86,3 +100,18 @@
.optionsList--sortPopover {
width: $euiSizeXL * 7;
}
+
+.optionslist--loadingMoreGroupLabel {
+ text-align: center;
+ padding: $euiSizeM;
+ font-style: italic;
+ height: $euiSizeXXL !important;
+}
+
+.optionslist--endOfOptionsGroupLabel {
+ text-align: center;
+ font-size: $euiSizeM;
+ height: auto !important;
+ color: $euiTextSubduedColor;
+ padding: $euiSizeM;
+}
diff --git a/src/plugins/controls/public/options_list/components/options_list_control.test.tsx b/src/plugins/controls/public/options_list/components/options_list_control.test.tsx
index a4d5028f0f7be..7fe1cd2f7aa78 100644
--- a/src/plugins/controls/public/options_list/components/options_list_control.test.tsx
+++ b/src/plugins/controls/public/options_list/components/options_list_control.test.tsx
@@ -20,6 +20,7 @@ import { BehaviorSubject } from 'rxjs';
describe('Options list control', () => {
const defaultProps = {
typeaheadSubject: new BehaviorSubject(''),
+ loadMoreSubject: new BehaviorSubject(10),
};
interface MountOptions {
diff --git a/src/plugins/controls/public/options_list/components/options_list_control.tsx b/src/plugins/controls/public/options_list/components/options_list_control.tsx
index 98f545718efc6..7906d77730f0d 100644
--- a/src/plugins/controls/public/options_list/components/options_list_control.tsx
+++ b/src/plugins/controls/public/options_list/components/options_list_control.tsx
@@ -17,20 +17,24 @@ import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'
import { OptionsListStrings } from './options_list_strings';
import { OptionsListPopover } from './options_list_popover';
import { optionsListReducers } from '../options_list_reducers';
-import { OptionsListReduxState } from '../types';
+import { MAX_OPTIONS_LIST_REQUEST_SIZE, OptionsListReduxState } from '../types';
import './options_list.scss';
-export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Subject }) => {
- const [isPopoverOpen, setIsPopoverOpen] = useState(false);
-
+export const OptionsListControl = ({
+ typeaheadSubject,
+ loadMoreSubject,
+}: {
+ typeaheadSubject: Subject;
+ loadMoreSubject: Subject;
+}) => {
const resizeRef = useRef(null);
const dimensions = useResizeObserver(resizeRef.current);
// Redux embeddable Context
const {
useEmbeddableDispatch,
- actions: { replaceSelection, setSearchString },
+ actions: { replaceSelection, setSearchString, setPopoverOpen },
useEmbeddableSelector: select,
} = useReduxEmbeddableContext();
const dispatch = useEmbeddableDispatch();
@@ -38,6 +42,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
// Select current state from Redux using multiple selectors to avoid rerenders.
const invalidSelections = select((state) => state.componentState.invalidSelections);
const validSelections = select((state) => state.componentState.validSelections);
+ const isPopoverOpen = select((state) => state.componentState.popoverOpen);
const selectedOptions = select((state) => state.explicitInput.selectedOptions);
const existsSelected = select((state) => state.explicitInput.existsSelected);
@@ -51,6 +56,12 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
const loading = select((state) => state.output.loading);
+ useEffect(() => {
+ return () => {
+ dispatch(setPopoverOpen(false)); // on unmount, close the popover
+ };
+ }, [dispatch, setPopoverOpen]);
+
// debounce loading state so loading doesn't flash when user types
const [debouncedLoading, setDebouncedLoading] = useState(true);
const debounceSetLoading = useMemo(
@@ -77,6 +88,13 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
[typeaheadSubject, dispatch, setSearchString]
);
+ const loadMoreSuggestions = useCallback(
+ (cardinality: number) => {
+ loadMoreSubject.next(Math.min(cardinality, MAX_OPTIONS_LIST_REQUEST_SIZE));
+ },
+ [loadMoreSubject]
+ );
+
const { hasSelections, selectionDisplayNode, validSelectionsCount } = useMemo(() => {
return {
hasSelections: !isEmpty(validSelections) || !isEmpty(invalidSelections),
@@ -123,7 +141,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
'optionsList--filterBtnPlaceholder': !hasSelections,
})}
data-test-subj={`optionsList-control-${id}`}
- onClick={() => setIsPopoverOpen((openState) => !openState)}
+ onClick={() => dispatch(setPopoverOpen(!isPopoverOpen))}
isSelected={isPopoverOpen}
numActiveFilters={validSelectionsCount}
hasActiveFilters={Boolean(validSelectionsCount)}
@@ -149,7 +167,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
panelPaddingSize="none"
anchorPosition="downCenter"
className="optionsList__popoverOverride"
- closePopover={() => setIsPopoverOpen(false)}
+ closePopover={() => dispatch(setPopoverOpen(false))}
anchorClassName="optionsList__anchorOverride"
aria-label={OptionsListStrings.popover.getAriaLabel(fieldName)}
>
@@ -157,6 +175,7 @@ export const OptionsListControl = ({ typeaheadSubject }: { typeaheadSubject: Sub
width={dimensions.width}
isLoading={debouncedLoading}
updateSearchString={updateSearchString}
+ loadMoreSuggestions={loadMoreSuggestions}
/>
diff --git a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx
index a8504aba372c8..4b40414974b67 100644
--- a/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx
+++ b/src/plugins/controls/public/options_list/components/options_list_popover.test.tsx
@@ -11,11 +11,11 @@ import { ReactWrapper } from 'enzyme';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
+import { FieldSpec } from '@kbn/data-views-plugin/common';
import { OptionsListPopover, OptionsListPopoverProps } from './options_list_popover';
import { OptionsListComponentState, OptionsListReduxState } from '../types';
import { mockOptionsListReduxEmbeddableTools } from '../../../common/mocks';
-import { OptionsListField } from '../../../common/options_list/types';
import { ControlOutput, OptionsListEmbeddableInput } from '../..';
describe('Options list popover', () => {
@@ -23,6 +23,7 @@ describe('Options list popover', () => {
width: 500,
isLoading: false,
updateSearchString: jest.fn(),
+ loadMoreSuggestions: jest.fn(),
};
interface MountOptions {
@@ -63,7 +64,7 @@ describe('Options list popover', () => {
// the div cannot be smaller than 301 pixels wide
popover = await mountComponent({ popoverProps: { width: 300 } });
popoverDiv = findTestSubject(popover, 'optionsList-control-available-options');
- expect(popoverDiv.getDOMNode().getAttribute('style')).toBe(null);
+ expect(popoverDiv.getDOMNode().getAttribute('style')).toBe('width: 100%; height: 100%;');
});
test('no available options', async () => {
@@ -237,7 +238,7 @@ describe('Options list popover', () => {
test('when sorting suggestions, show both sorting types for keyword field', async () => {
const popover = await mountComponent({
componentState: {
- field: { name: 'Test keyword field', type: 'keyword' } as OptionsListField,
+ field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec,
},
});
const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
@@ -252,7 +253,7 @@ describe('Options list popover', () => {
const popover = await mountComponent({
explicitInput: { sort: { by: '_key', direction: 'asc' } },
componentState: {
- field: { name: 'Test keyword field', type: 'keyword' } as OptionsListField,
+ field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec,
},
});
const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
@@ -270,7 +271,7 @@ describe('Options list popover', () => {
test('when sorting suggestions, only show document count sorting for IP fields', async () => {
const popover = await mountComponent({
- componentState: { field: { name: 'Test IP field', type: 'ip' } as OptionsListField },
+ componentState: { field: { name: 'Test IP field', type: 'ip' } as FieldSpec },
});
const sortButton = findTestSubject(popover, 'optionsListControl__sortingOptionsButton');
sortButton.simulate('click');
@@ -280,6 +281,25 @@ describe('Options list popover', () => {
expect(optionsText).toEqual(['By document count - Checked option.']);
});
+ test('ensure warning icon does not show up when testAllowExpensiveQueries = true/undefined', async () => {
+ const popover = await mountComponent({
+ componentState: { field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec },
+ });
+ const warning = findTestSubject(popover, 'optionsList-allow-expensive-queries-warning');
+ expect(warning).toEqual({});
+ });
+
+ test('ensure warning icon shows up when testAllowExpensiveQueries = false', async () => {
+ const popover = await mountComponent({
+ componentState: {
+ field: { name: 'Test keyword field', type: 'keyword' } as FieldSpec,
+ allowExpensiveQueries: false,
+ },
+ });
+ const warning = findTestSubject(popover, 'optionsList-allow-expensive-queries-warning');
+ expect(warning.getDOMNode()).toBeInstanceOf(HTMLDivElement);
+ });
+
describe('Test advanced settings', () => {
const ensureComponentIsHidden = async ({
explicitInput,
diff --git a/src/plugins/controls/public/options_list/components/options_list_popover.tsx b/src/plugins/controls/public/options_list/components/options_list_popover.tsx
index b5562c43f3be2..df5f3d00730d0 100644
--- a/src/plugins/controls/public/options_list/components/options_list_popover.tsx
+++ b/src/plugins/controls/public/options_list/components/options_list_popover.tsx
@@ -9,12 +9,12 @@
import React, { useState } from 'react';
import { isEmpty } from 'lodash';
-import { EuiPopoverTitle } from '@elastic/eui';
import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public';
import { OptionsListReduxState } from '../types';
import { OptionsListStrings } from './options_list_strings';
import { optionsListReducers } from '../options_list_reducers';
+import { OptionsListPopoverTitle } from './options_list_popover_title';
import { OptionsListPopoverFooter } from './options_list_popover_footer';
import { OptionsListPopoverActionBar } from './options_list_popover_action_bar';
import { OptionsListPopoverSuggestions } from './options_list_popover_suggestions';
@@ -23,6 +23,7 @@ import { OptionsListPopoverInvalidSelections } from './options_list_popover_inva
export interface OptionsListPopoverProps {
width: number;
isLoading: boolean;
+ loadMoreSuggestions: (cardinality: number) => void;
updateSearchString: (newSearchString: string) => void;
}
@@ -30,6 +31,7 @@ export const OptionsListPopover = ({
width,
isLoading,
updateSearchString,
+ loadMoreSuggestions,
}: OptionsListPopoverProps) => {
// Redux embeddable container Context
const { useEmbeddableSelector: select } = useReduxEmbeddableContext<
@@ -42,39 +44,45 @@ export const OptionsListPopover = ({
const availableOptions = select((state) => state.componentState.availableOptions);
const field = select((state) => state.componentState.field);
- const hideExclude = select((state) => state.explicitInput.hideExclude);
const hideActionBar = select((state) => state.explicitInput.hideActionBar);
+ const hideExclude = select((state) => state.explicitInput.hideExclude);
const fieldName = select((state) => state.explicitInput.fieldName);
- const title = select((state) => state.explicitInput.title);
const id = select((state) => state.explicitInput.id);
const [showOnlySelected, setShowOnlySelected] = useState(false);
return (
-